@shawaze/agentspace 0.3.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/dist/cli.js ADDED
@@ -0,0 +1,1083 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { pathToFileURL } from "url";
5
+
6
+ // src/shape.ts
7
+ var CONTRACT_SHAPES = /* @__PURE__ */ new Set([
8
+ "one-product",
9
+ "peer-services",
10
+ "library-consumers"
11
+ ]);
12
+ var ORDERED_SHAPES = /* @__PURE__ */ new Set([
13
+ "one-product",
14
+ "library-consumers"
15
+ ]);
16
+ function shapeHasContracts(shape) {
17
+ return CONTRACT_SHAPES.has(shape);
18
+ }
19
+ function shapeHasDependencyOrder(shape) {
20
+ return ORDERED_SHAPES.has(shape);
21
+ }
22
+ function isContractLinked(config) {
23
+ return shapeHasContracts(config.shape) && config.repos.length >= 2;
24
+ }
25
+
26
+ // src/templates/memoryBank.ts
27
+ var WIKI_FOLDERS = [
28
+ "00-core",
29
+ "01-active",
30
+ "02-architecture",
31
+ "03-patterns",
32
+ "04-business",
33
+ "05-sessions",
34
+ "06-learnings",
35
+ "07-reference",
36
+ "08-history",
37
+ "09-research",
38
+ "10-archive"
39
+ ];
40
+ var WIKI_README = `# Memory Bank \u2014 {{workspaceName}} Wiki
41
+
42
+ An LLM-curated knowledge base (Karpathy LLM Wiki pattern): entity/concept pages,
43
+ an \`index.md\` catalog, an append-only \`log.md\`, and operations (ingest / query / lint).
44
+
45
+ ## Scope
46
+ {{#isOneProduct}}**Cross-app / product-level only.** Per-repo architecture stays in each
47
+ sub-repo's own CLAUDE.md \u2014 do not duplicate it here (duplication drifts).
48
+ {{/isOneProduct}}{{^isOneProduct}}Cross-repo knowledge that spans more than one repository.
49
+ Per-repo detail stays in each sub-repo's own CLAUDE.md.
50
+ {{/isOneProduct}}
51
+
52
+ ## Folders (lower number = higher reading priority)
53
+ | Dir | Holds |
54
+ |---|---|
55
+ | 00-core/ | Foundational, slow-changing |
56
+ | 01-active/ | Current focus, status, next steps |
57
+ | 04-business/ | Domain/feature pages |
58
+ | 06-learnings/ | Lessons, postmortems |
59
+ | 10-archive/ | Superseded material (archive, don't delete) |
60
+
61
+ ## Page conventions
62
+ - Scannable, not exhaustive. Bullets/tables. Reference \`file:line\`, don't duplicate code.
63
+ - Every entity page ends with \`_Last verified: YYYY-MM-DD_\`.
64
+ - Every factual claim about code cites \`file:line\`. Without citations, drift is invisible.
65
+
66
+ ## Size budgets
67
+ \`agentspace doctor\` enforces these mechanically (single source of truth):
68
+ | File / pattern | Hard cap (lines) |
69
+ |---|---|
70
+ | log.md | 500 |
71
+ | 00-core/*.md | 800 |
72
+ | 01-active/currentWork.md | 150 |
73
+ | 04-business/*.md | 800 |
74
+ `;
75
+ var WIKI_INDEX = `# Index
76
+
77
+ Catalog of wiki pages by category. Updated on every \`/ingest\`.
78
+
79
+ _(empty \u2014 add pages as you ingest)_
80
+ `;
81
+ var WIKI_LOG = `# Log
82
+
83
+ Append-only activity journal. One line per action: \`## [YYYY-MM-DD] <action> | <slug>\`.
84
+ `;
85
+ var PROJECT_OVERVIEW = `# Project Overview \u2014 {{workspaceName}}
86
+
87
+ {{#isOneProduct}}One product across {{repoCount}} repositories.{{/isOneProduct}}{{^isOneProduct}}{{repoCount}} repositories coordinated in one workspace.{{/isOneProduct}}
88
+
89
+ ## Repos
90
+ {{#repos}}- \`{{name}}/\` \u2014 {{role}} ({{stack}})
91
+ {{/repos}}
92
+
93
+ ---
94
+ _Last verified: {{today}}_
95
+ `;
96
+ var CROSS_APP_CONTRACTS = `# Cross-App Contracts \u2014 {{workspaceName}}
97
+
98
+ > No contracts recorded yet. Populate this after your first cross-repo change.
99
+ > Every entry must cite \`file:line\` and end the page with a verified date footer.
100
+ {{#hasContracts}}> Cite the matching \`openspec/specs/<capability>/spec.md\` for each contract recorded here.
101
+ {{/hasContracts}}
102
+
103
+ {{#dependencyOrder.length}}**Dependency order:** {{#dependencyOrder}}{{.}} \u2192 {{/dependencyOrder}}(done)
104
+ {{/dependencyOrder.length}}
105
+ `;
106
+
107
+ // src/context/build.ts
108
+ function buildContext(config, today) {
109
+ const pillars = config.pillars;
110
+ const contractLinked = isContractLinked(config);
111
+ const hasContracts = pillars.includes("contracts") && contractLinked;
112
+ return {
113
+ config,
114
+ manifest: {
115
+ workspaceName: config.workspaceName,
116
+ shape: config.shape,
117
+ repos: config.repos,
118
+ contractLinked,
119
+ enforcement: config.enforcement,
120
+ hasContracts
121
+ },
122
+ wiki: {
123
+ workspaceName: config.workspaceName,
124
+ shape: config.shape,
125
+ isOneProduct: config.shape === "one-product",
126
+ contractLinked,
127
+ repos: config.repos,
128
+ dependencyOrder: config.dependencyOrder,
129
+ today,
130
+ hasContracts
131
+ },
132
+ enforcement: config.enforcement ? {
133
+ workspaceName: config.workspaceName,
134
+ shape: config.shape,
135
+ contractLinked,
136
+ repos: config.repos,
137
+ config: { ...config.enforcement },
138
+ folders: WIKI_FOLDERS
139
+ } : null,
140
+ contracts: pillars.includes("contracts") ? {
141
+ workspaceName: config.workspaceName,
142
+ shape: config.shape,
143
+ contractLinked,
144
+ repos: config.repos,
145
+ dependencyOrder: config.dependencyOrder,
146
+ hasWiki: pillars.includes("wiki")
147
+ } : null
148
+ };
149
+ }
150
+
151
+ // src/renderer/render.ts
152
+ import Mustache from "mustache";
153
+ Mustache.escape = (text2) => text2;
154
+ function render(template, view) {
155
+ return Mustache.render(template, view);
156
+ }
157
+
158
+ // src/templates/manifest.ts
159
+ var MANIFEST_YAML = `# {{workspaceName}} \u2014 workspace manifest (source of truth for sub-repos)
160
+ workspace: {{workspaceName}}
161
+ shape: {{shape}}
162
+ repos:
163
+ {{#repos}} - name: {{name}}
164
+ remote: {{remote}}
165
+ stack: {{stack}}
166
+ role: {{role}}
167
+ {{/repos}}{{#enforcement}}enforcement:
168
+ mode: {{enforcement.mode}}
169
+ warmPages: {{enforcement.warmPages}}
170
+ warmSessions: {{enforcement.warmSessions}}
171
+ {{/enforcement}}`;
172
+ var CLONE_REPOS_SH = `#!/usr/bin/env bash
173
+ set -euo pipefail
174
+ # Reconstructs the workspace from the inlined repo list below.
175
+ # Skips repos that already exist (idempotent) and local-only repos (no remote).
176
+
177
+ # Format per line: "<name>\\t<remote>" (empty remote = local-only)
178
+ REPOS=(
179
+ {{#repos}} "{{name}} {{remoteOrEmpty}}"
180
+ {{/repos}})
181
+
182
+ for entry in "\${REPOS[@]}"; do
183
+ name="\${entry%% *}"
184
+ remote="\${entry#* }"
185
+ if [[ -d "$name" ]]; then
186
+ echo "skip $name (already present)"
187
+ elif [[ -z "$remote" ]]; then
188
+ echo "skip $name (local-only, no remote)"
189
+ else
190
+ echo "clone $name"
191
+ if ! git clone "$remote" "$name"; then
192
+ echo "FAILED $name (continuing)"
193
+ fi
194
+ fi
195
+ done
196
+ `;
197
+ var GITIGNORE = `# Sub-repos are independent git repositories, not tracked here.
198
+ {{#repos}}{{name}}/
199
+ {{/repos}}{{#enforcement}}.agentspace/state.json
200
+ {{/enforcement}}# Machine-local Claude Code settings.
201
+ **/.claude/settings.local.json
202
+ .DS_Store
203
+ `;
204
+ var ROOT_CLAUDE = `# CLAUDE.md \u2014 {{workspaceName}}
205
+
206
+ This is an agentspace workspace: a coordination layer above {{repoCount}} sibling repos.
207
+ Work inside the relevant sub-repo; read that repo's own CLAUDE.md for stack details.
208
+
209
+ ## Repos
210
+ {{#repos}}- \`{{name}}/\` \u2014 {{role}} ({{stack}})
211
+ {{/repos}}
212
+
213
+ ## Source of truth
214
+ - \`manifest.yaml\` \u2014 canonical repo list. Run \`./clone-repos.sh\` to reconstruct.
215
+ - \`memory-bank/\` \u2014 cross-repo knowledge wiki (read \`memory-bank/README.md\`).
216
+ {{#parallelAgents}}
217
+ ## Parallel agents
218
+
219
+ For features spanning repos, work in dependency order. One git worktree per
220
+ repo; dispatch that repo's \`<repo>-engineer\` agent in each; finish with the
221
+ \`cross-app-reviewer\` on the combined diff. Two parallel agents is comfortable;
222
+ cap around 3\u20135 before human review becomes the bottleneck.
223
+ {{/parallelAgents}}{{#hasContracts}}
224
+
225
+ ## Cross-repo contracts
226
+
227
+ Before changing any API or shared shape across repos, read \`openspec/project.md\`
228
+ and propose the change there (\`/opsx:propose\`).
229
+ {{/hasContracts}}`;
230
+ var ROOT_README = `# {{workspaceName}}
231
+
232
+ Coordination workspace for {{repoCount}} sibling repositories, scaffolded by agentspace.
233
+
234
+ ## Repositories
235
+ | Dir | Role | Stack |
236
+ |---|---|---|
237
+ {{#repos}}| \`{{name}}/\` | {{role}} | {{stack}} |
238
+ {{/repos}}
239
+
240
+ ## Getting started
241
+ \`\`\`bash
242
+ ./clone-repos.sh # clone any missing sub-repos (idempotent)
243
+ \`\`\`
244
+
245
+ Sub-repos are independent git repositories and are git-ignored by this workspace.
246
+ `;
247
+
248
+ // src/generators/manifest.ts
249
+ function generateManifest(ctx) {
250
+ const repos = ctx.repos.map((r) => ({
251
+ ...r,
252
+ remote: r.remote ?? "",
253
+ remoteOrEmpty: r.remote ?? ""
254
+ }));
255
+ const view = {
256
+ workspaceName: ctx.workspaceName,
257
+ shape: ctx.shape,
258
+ repoCount: ctx.repos.length,
259
+ repos,
260
+ enforcement: ctx.enforcement,
261
+ // mustache section: truthy object renders the block
262
+ parallelAgents: ctx.contractLinked && ctx.repos.length > 1,
263
+ hasContracts: ctx.hasContracts
264
+ };
265
+ return [
266
+ { path: "manifest.yaml", contents: render(MANIFEST_YAML, view) },
267
+ { path: "clone-repos.sh", contents: render(CLONE_REPOS_SH, view) },
268
+ { path: ".gitignore", contents: render(GITIGNORE, view) },
269
+ { path: "CLAUDE.md", contents: render(ROOT_CLAUDE, view) },
270
+ { path: "README.md", contents: render(ROOT_README, view) }
271
+ ];
272
+ }
273
+
274
+ // src/generators/memoryBank.ts
275
+ function generateMemoryBank(ctx) {
276
+ const view = {
277
+ workspaceName: ctx.workspaceName,
278
+ isOneProduct: ctx.isOneProduct,
279
+ repoCount: ctx.repos.length,
280
+ repos: ctx.repos,
281
+ dependencyOrder: ctx.dependencyOrder ?? [],
282
+ today: ctx.today,
283
+ hasContracts: ctx.hasContracts
284
+ };
285
+ const files = [];
286
+ for (const folder of WIKI_FOLDERS) {
287
+ files.push({ path: `memory-bank/${folder}/.gitkeep`, contents: "" });
288
+ }
289
+ files.push(
290
+ { path: "memory-bank/README.md", contents: render(WIKI_README, view) },
291
+ { path: "memory-bank/index.md", contents: render(WIKI_INDEX, view) },
292
+ { path: "memory-bank/log.md", contents: render(WIKI_LOG, view) },
293
+ {
294
+ path: "memory-bank/00-core/projectOverview.md",
295
+ contents: render(PROJECT_OVERVIEW, view)
296
+ }
297
+ );
298
+ if (ctx.contractLinked) {
299
+ files.push({
300
+ path: "memory-bank/00-core/crossAppContracts.md",
301
+ contents: render(CROSS_APP_CONTRACTS, view)
302
+ });
303
+ }
304
+ return files;
305
+ }
306
+
307
+ // src/templates/commands.ts
308
+ var INGEST = `---
309
+ description: Ingest a source into the {{workspaceName}} memory-bank (LLM Wiki pattern)
310
+ ---
311
+
312
+ You are operating in **INGEST mode** for the {{workspaceName}} memory-bank.
313
+
314
+ Source to ingest: $ARGUMENTS
315
+
316
+ ## No-ingest gate (apply first)
317
+ Skip the ingest and tell the user if any are true:
318
+ - The content is derivable from \`git log\` / \`grep\` / reading code directly.
319
+ - You can't name **two future questions** the page would answer.
320
+ - It's one-off setup chatter, not durable cross-repo knowledge.
321
+
322
+ ## Steps
323
+ 1. **Read the source** (file, URL, or pasted content).
324
+ 2. **Discuss takeaways** briefly (3\u20135 bullets) so the user can correct course.
325
+ 3. **Classify and pick a folder** under \`memory-bank/\`:
326
+ {{#folders}} - \`{{.}}/\`
327
+ {{/folders}}
328
+ 4. **Write a concise page** \u2014 bullets, tables, citations. \u2264 150 lines.
329
+ 5. **Cite \`file:line\`** for every factual claim about the codebase.
330
+ 6. **Last-verified footer:** end with \`_Last verified: YYYY-MM-DD_\`.
331
+ 7. **Cross-link** related pages with \`[[slug]]\`.
332
+ 8. **Update \`memory-bank/index.md\`** \u2014 add an entry under the right category.
333
+ 9. **Append to \`memory-bank/log.md\`:** \`## [YYYY-MM-DD] ingest | <slug>\`
334
+ 10. **Report what changed.**
335
+
336
+ ## Rules
337
+ Reference, don't duplicate \u2014 point at \`file:line\` rather than copying code.
338
+ Scannable beats comprehensive.
339
+ `;
340
+ var QUERY = `---
341
+ description: Query the {{workspaceName}} memory-bank; file useful answers back
342
+ ---
343
+
344
+ You are operating in **QUERY mode** for the {{workspaceName}} memory-bank.
345
+
346
+ Question: $ARGUMENTS
347
+
348
+ ## Steps
349
+ 1. **Read \`memory-bank/index.md\`** first \u2014 see what pages exist.
350
+ 2. **Read relevant pages** \u2014 \`00-core/\` first for constraints, then concept
351
+ pages. Walk \`[[cross-links]]\` as needed.
352
+ 3. **Classify the question:**
353
+ - **Behavioral** (what the code does / returns) \u2192 wiki is a hint; **verify the
354
+ cited \`file:line\` against the code** before answering.
355
+ - **Decision / history** (why we chose Y) \u2192 wiki answer stands.
356
+ 4. **Synthesize with citations** \u2014 link pages with \`[[slug]]\`, cite repo
357
+ evidence as \`file:line\`.
358
+ 5. **If a page's \`Last verified\` is > 30 days old** and you used it for a
359
+ behavioral answer, call it out.
360
+ 6. **If the wiki lacks the answer**, search the repos, then ask:
361
+ _"Found in \`<source>\`. Worth filing in \`memory-bank/<folder>/<slug>.md\`? (y/n)"_
362
+ 7. **Append to \`memory-bank/log.md\`:** \`## [YYYY-MM-DD] query | <topic>\`
363
+
364
+ ## Rules
365
+ Wiki is a hint, code is truth for behavioral claims. Always cite. Never fabricate.
366
+ `;
367
+ var LINT = `---
368
+ description: Lint the {{workspaceName}} memory-bank for staleness, drift, and scope violations
369
+ ---
370
+
371
+ You are operating in **LINT mode** for the {{workspaceName}} memory-bank.
372
+
373
+ ## Step 1 \u2014 mechanical checks (run the tool)
374
+ Run \`agentspace doctor --lint\` and read its JSON findings. These cover size
375
+ budgets, \`_Last verified:_\` staleness, and orphan/citation-path checks \u2014 the
376
+ single source of truth for mechanical rules. Do not re-derive them by hand.
377
+
378
+ ## Step 2 \u2014 judgment checks (only the LLM can do these)
379
+ Sweep the wiki for:
380
+ 1. **Broken citations** \u2014 a \`file:line\` whose line no longer matches the claim
381
+ (spot-check \u22653 per page).
382
+ 2. **Contradictions** \u2014 two pages making incompatible claims.
383
+ 3. **Stale state** \u2014 "in progress" work that's shipped; SHAs that don't exist.
384
+ 4. **Out-of-scope content** \u2014 per-repo detail that belongs in a repo's CLAUDE.md.
385
+ 5. **Broken cross-links** \u2014 \`[[slug]]\` pointing at non-existent pages.
386
+
387
+ ## Report
388
+ Output a table: \`Severity | File | Issue | Suggested fix\`. Merge the tool's
389
+ mechanical findings with your judgment findings. **Do NOT fix automatically** \u2014
390
+ ask the user which to act on. Then append to \`memory-bank/log.md\`:
391
+ \`## [YYYY-MM-DD] lint | <H high \xB7 M med \xB7 L low \u2014 short summary>\`
392
+ `;
393
+
394
+ // src/stackAgents/loader.ts
395
+ import { existsSync as existsSync2, readFileSync } from "fs";
396
+ import { join as join2 } from "path";
397
+ import { parse } from "yaml";
398
+
399
+ // src/paths.ts
400
+ import { existsSync } from "fs";
401
+ import { dirname, join } from "path";
402
+ import { fileURLToPath } from "url";
403
+ function packageRoot() {
404
+ let dir = dirname(fileURLToPath(import.meta.url));
405
+ for (let i = 0; i < 8; i++) {
406
+ if (existsSync(join(dir, "package.json"))) return dir;
407
+ const parent = dirname(dir);
408
+ if (parent === dir) break;
409
+ dir = parent;
410
+ }
411
+ throw new Error("agentspace: could not locate package root from " + import.meta.url);
412
+ }
413
+ function packageDir(name) {
414
+ return join(packageRoot(), name);
415
+ }
416
+
417
+ // src/stackAgents/loader.ts
418
+ function stacksDir() {
419
+ return packageDir("stack-agents");
420
+ }
421
+ var cached = null;
422
+ function registry() {
423
+ if (!cached) {
424
+ cached = parse(readFileSync(join2(stacksDir(), "stacks.yaml"), "utf8"));
425
+ }
426
+ return cached;
427
+ }
428
+ function engineerToolList() {
429
+ return registry().engineerTools;
430
+ }
431
+ function resolveStackId(input) {
432
+ const norm = input.trim().toLowerCase();
433
+ if (norm === "" || norm === "generic" || norm === "_generic") return "_generic";
434
+ for (const s of registry().stacks) {
435
+ if (s.id === norm || s.aliases.includes(norm)) return s.id;
436
+ }
437
+ return "_generic";
438
+ }
439
+ function loadStackBody(input) {
440
+ const id = resolveStackId(input);
441
+ const file = join2(stacksDir(), `${id}.md`);
442
+ if (existsSync2(file)) return readFileSync(file, "utf8");
443
+ console.warn(`agentspace: stack file ${id}.md missing; using _generic`);
444
+ return readFileSync(join2(stacksDir(), "_generic.md"), "utf8");
445
+ }
446
+
447
+ // src/generators/enforcement.ts
448
+ var REVIEWER_TOOLS = ["Read", "Grep", "Glob", "Bash"];
449
+ function generateEnforcementIntents(ctx) {
450
+ const agents = ctx.repos.map((repo) => ({
451
+ name: `${repo.name}-engineer`,
452
+ repoDir: repo.name,
453
+ role: repo.role,
454
+ stack: resolveStackId(repo.stack),
455
+ boundaryRule: `You only edit files inside \`${repo.name}/\`. Never touch another repo or \`memory-bank/\` (except read-only).`,
456
+ toolList: engineerToolList(),
457
+ isReviewer: false
458
+ }));
459
+ if (ctx.contractLinked) {
460
+ agents.push({
461
+ name: "cross-app-reviewer",
462
+ repoDir: "",
463
+ role: "read-only cross-repo reviewer",
464
+ stack: "generic",
465
+ boundaryRule: "Read-only. You never edit any file.",
466
+ toolList: REVIEWER_TOOLS,
467
+ isReviewer: true
468
+ });
469
+ }
470
+ const view = { workspaceName: ctx.workspaceName, folders: ctx.folders };
471
+ const commands = [
472
+ { name: "ingest", body: render(INGEST, view) },
473
+ { name: "query", body: render(QUERY, view) },
474
+ { name: "lint", body: render(LINT, view) }
475
+ ];
476
+ const hook = ctx.contractLinked ? {
477
+ enabled: true,
478
+ mode: ctx.config.mode,
479
+ warmPages: ctx.config.warmPages,
480
+ warmSessions: ctx.config.warmSessions,
481
+ subRepos: ctx.repos.map((r) => r.name)
482
+ } : null;
483
+ return { agents, commands, hook };
484
+ }
485
+
486
+ // src/templates/contracts.ts
487
+ var PROJECT_MD = `# Project Context \u2014 {{workspaceName}} (cross-repo contracts)
488
+
489
+ > **Scope:** this OpenSpec instance covers **cross-repo contracts only** \u2014 things
490
+ > consumed by more than one repo in this workspace. Per-repo internals do not
491
+ > belong here; they stay in that repo.
492
+
493
+ ## Repos
494
+ | Repo | Role |
495
+ |---|---|
496
+ {{#repos}}| \`{{name}}/\` | {{role}} |
497
+ {{/repos}}
498
+ ## What belongs in \`specs/\`
499
+ A capability lives here only if it's a contract **between** repos:
500
+ - HTTP endpoints consumed by another repo
501
+ - Shared data shapes / payloads
502
+ - Auth flows
503
+ - Webhook payloads that cross repos
504
+ - Cross-repo events
505
+
506
+ It does **not** belong here if it only describes the inside of one repo.
507
+
508
+ ## What belongs in \`changes/\`
509
+ Any proposal that mutates a cross-repo contract, before implementation (adding a
510
+ field consumers read, changing an auth response, deprecating an endpoint).
511
+ {{#hasOrder}}Each change names the affected repos and orders tasks in
512
+ **dependency order: {{#order}}{{.}} \u2192 {{/order}}done**. The producer defines the
513
+ contract; consumers follow.{{/hasOrder}}{{^hasOrder}}These repos are **peers** with
514
+ no global dependency order \u2014 each change names the affected repos and the contract
515
+ between them.{{/hasOrder}}
516
+
517
+ ## Working with this instance
518
+ The \`/opsx:*\` slash commands are installed by the external **\`openspec\` CLI** \u2014
519
+ run \`openspec update\` in this workspace to install them. CLI: \`openspec list\`,
520
+ \`openspec validate\`, \`openspec show <name>\`, \`openspec view\`.
521
+
522
+ | Command | Purpose |
523
+ |---|---|
524
+ | \`/opsx:propose <idea>\` | Scaffold \`changes/<name>/{proposal,design,tasks}.md\` |
525
+ | \`/opsx:apply <name>\` | Implement a change task-by-task |
526
+ | \`/opsx:archive <name>\` | Fold a shipped change into \`specs/\` (archive on deploy) |
527
+
528
+ ## Relationship to the memory bank
529
+ OpenSpec holds *what the contract is* (specs) and *what's changing* (proposals).
530
+ \`memory-bank/\` holds *why* (decisions, history).{{#hasWiki}} The wiki's
531
+ \`00-core/crossAppContracts.md\` should cite the matching
532
+ \`openspec/specs/<capability>/spec.md\`.{{/hasWiki}}
533
+ `;
534
+ var OPENSPEC_README = `# openspec/ \u2014 {{workspaceName}} cross-repo contracts
535
+
536
+ Prescriptive contract layer for this workspace. Read \`project.md\` for scope and
537
+ lifecycle.
538
+
539
+ - \`specs/\` \u2014 current cross-repo capabilities (the truth).
540
+ - \`changes/\` \u2014 proposals in flight; \`changes/archive/\` \u2014 shipped.
541
+
542
+ The \`/opsx:*\` commands and validation come from the external **\`openspec\` CLI**.
543
+ Run \`openspec update\` to install the slash commands; \`openspec validate\` to check.
544
+ `;
545
+
546
+ // src/generators/contracts.ts
547
+ function generateContracts(ctx) {
548
+ if (!ctx.contractLinked) return [];
549
+ const view = {
550
+ workspaceName: ctx.workspaceName,
551
+ repos: ctx.repos,
552
+ hasOrder: ctx.dependencyOrder !== null && ctx.dependencyOrder.length > 0,
553
+ order: ctx.dependencyOrder ?? [],
554
+ hasWiki: ctx.hasWiki
555
+ };
556
+ return [
557
+ { path: "openspec/project.md", contents: render(PROJECT_MD, view) },
558
+ { path: "openspec/README.md", contents: render(OPENSPEC_README, view) },
559
+ { path: "openspec/specs/.gitkeep", contents: "" },
560
+ { path: "openspec/changes/.gitkeep", contents: "" },
561
+ { path: "openspec/changes/archive/.gitkeep", contents: "" }
562
+ ];
563
+ }
564
+
565
+ // src/adapters/claudeCode.ts
566
+ import { readFileSync as readFileSync2 } from "fs";
567
+ import { join as join3 } from "path";
568
+
569
+ // src/templates/agents.ts
570
+ var REVIEWER_AGENT = `---
571
+ name: cross-app-reviewer
572
+ description: Read-only reviewer that catches cross-repo breakage in {{workspaceName}}. Use when a change touches an API, shared data shape, or auth flow across repos.
573
+ tools: Read, Grep, Glob, Bash
574
+ ---
575
+
576
+ You are the **Cross-app reviewer** for the {{workspaceName}} workspace.
577
+
578
+ ## Scope (hard boundary)
579
+ **Read-only.** You never edit any file. If the user asks for a fix, report
580
+ findings and stop \u2014 the relevant repo's engineer applies the change.
581
+
582
+ ## What you do
583
+ For a given change (diff, PR, or spec), check whether it breaks any contract
584
+ between the repos:
585
+ 1. **Read \`memory-bank/00-core/crossAppContracts.md\` first** \u2014 that's your map.
586
+ 2. **Identify what the change touches** \u2014 API routes, shared payload shapes,
587
+ auth flow, client API code.
588
+ 3. **For each touched contract:** is the wiki entry still accurate (cite wiki
589
+ \`file:line\` and code \`file:line\`)? Does any consumer break (grep across
590
+ client repos)? Is the change additive (safe) or breaking?
591
+ 4. **Report** a table: \`severity \xB7 what changed \xB7 who breaks \xB7 suggested fix\`.
592
+
593
+ ## Severity
594
+ - **CRITICAL** \u2014 breaks a current consumer or the auth contract.
595
+ - **HIGH** \u2014 a likely break or an undocumented contract change.
596
+ - **MEDIUM/LOW** \u2014 additive change; doc drift.
597
+ `;
598
+
599
+ // src/adapters/claudeCode.ts
600
+ var HOOK_ASSET = "memory-bank-stop.cjs";
601
+ function renderAgent(agent, workspaceName) {
602
+ if (agent.isReviewer) {
603
+ return render(REVIEWER_AGENT, { workspaceName });
604
+ }
605
+ const body = loadStackBody(agent.stack);
606
+ return render(body, {
607
+ repoName: agent.repoDir,
608
+ repoDir: agent.repoDir,
609
+ role: agent.role,
610
+ boundaryRule: agent.boundaryRule
611
+ });
612
+ }
613
+ function claudeCodeAdapter(intents, ctx) {
614
+ const files = [];
615
+ for (const agent of intents.agents) {
616
+ files.push({
617
+ path: `.claude/agents/${agent.name}.md`,
618
+ contents: renderAgent(agent, ctx.workspaceName)
619
+ });
620
+ }
621
+ for (const cmd of intents.commands) {
622
+ files.push({ path: `.claude/commands/${cmd.name}.md`, contents: cmd.body });
623
+ }
624
+ if (intents.hook) {
625
+ const hookSource = readFileSync2(join3(packageDir("assets"), HOOK_ASSET), "utf8");
626
+ files.push({ path: `.claude/hooks/${HOOK_ASSET}`, contents: hookSource });
627
+ files.push({
628
+ path: ".claude/agentspace-hook.json",
629
+ contents: JSON.stringify(
630
+ {
631
+ mode: intents.hook.mode,
632
+ warmPages: intents.hook.warmPages,
633
+ warmSessions: intents.hook.warmSessions,
634
+ subRepos: intents.hook.subRepos
635
+ },
636
+ null,
637
+ 2
638
+ ) + "\n"
639
+ });
640
+ files.push({
641
+ path: ".claude/settings.json",
642
+ contents: JSON.stringify(
643
+ {
644
+ hooks: {
645
+ Stop: [
646
+ {
647
+ matcher: "*",
648
+ hooks: [
649
+ {
650
+ type: "command",
651
+ command: `node "$CLAUDE_PROJECT_DIR/.claude/hooks/${HOOK_ASSET}"`,
652
+ timeout: 10,
653
+ statusMessage: "Checking memory bank..."
654
+ }
655
+ ]
656
+ }
657
+ ]
658
+ }
659
+ },
660
+ null,
661
+ 2
662
+ ) + "\n"
663
+ });
664
+ }
665
+ return files;
666
+ }
667
+
668
+ // src/fs/writeTree.ts
669
+ import {
670
+ cp,
671
+ mkdir,
672
+ mkdtemp,
673
+ readdir,
674
+ rename,
675
+ rm,
676
+ writeFile
677
+ } from "fs/promises";
678
+ import { dirname as dirname2, join as join4 } from "path";
679
+ async function isNonEmptyDir(dir) {
680
+ try {
681
+ const entries = await readdir(dir);
682
+ return entries.length > 0;
683
+ } catch {
684
+ return false;
685
+ }
686
+ }
687
+ async function writeTree(files, targetDir, opts) {
688
+ if (!opts.force && await isNonEmptyDir(targetDir)) {
689
+ throw new Error(
690
+ `Target directory is not empty: ${targetDir}. Re-run with --force to write anyway.`
691
+ );
692
+ }
693
+ const parent = dirname2(targetDir);
694
+ await mkdir(parent, { recursive: true });
695
+ const temp = await mkdtemp(join4(parent, ".agentspace-tmp-"));
696
+ try {
697
+ for (const file of files) {
698
+ const dest = join4(temp, file.path);
699
+ await mkdir(dirname2(dest), { recursive: true });
700
+ await writeFile(dest, file.contents);
701
+ }
702
+ if (opts.force) {
703
+ await cp(temp, targetDir, { recursive: true, force: true });
704
+ await rm(temp, { recursive: true, force: true });
705
+ } else if (await isNonEmptyDir(targetDir)) {
706
+ await cp(temp, targetDir, { recursive: true, force: true });
707
+ await rm(temp, { recursive: true, force: true });
708
+ } else {
709
+ await rm(targetDir, { recursive: true, force: true }).catch(() => {
710
+ });
711
+ await rename(temp, targetDir);
712
+ }
713
+ } catch (err) {
714
+ await rm(temp, { recursive: true, force: true }).catch(() => {
715
+ });
716
+ throw err;
717
+ }
718
+ }
719
+
720
+ // src/wizard/run.ts
721
+ import * as p from "@clack/prompts";
722
+
723
+ // src/types.ts
724
+ var DEFAULT_ENFORCEMENT = {
725
+ mode: "auto",
726
+ warmPages: 5,
727
+ warmSessions: 10
728
+ };
729
+
730
+ // src/wizard/assemble.ts
731
+ function assembleConfig(answers) {
732
+ const pillars = ["manifest"];
733
+ if (answers.enableWiki) pillars.push("wiki");
734
+ if (answers.enableEnforcement) pillars.push("enforcement");
735
+ if (answers.enableContracts) pillars.push("contracts");
736
+ return {
737
+ workspaceName: answers.workspaceName.trim(),
738
+ shape: answers.shape,
739
+ repos: answers.repos.map((r) => ({
740
+ name: r.name.trim(),
741
+ remote: r.remote.trim() === "" ? null : r.remote.trim(),
742
+ stack: r.stack,
743
+ role: r.role.trim()
744
+ })),
745
+ dependencyOrder: shapeHasDependencyOrder(answers.shape) ? answers.dependencyOrder : null,
746
+ pillars,
747
+ enforcement: answers.enableEnforcement ? { ...DEFAULT_ENFORCEMENT } : null
748
+ };
749
+ }
750
+
751
+ // src/wizard/validate.ts
752
+ var FS_SAFE = /^[A-Za-z0-9._-]+$/;
753
+ function validateWorkspaceName(name) {
754
+ if (!name.trim()) return "Workspace name is required.";
755
+ return null;
756
+ }
757
+ function validateRepoName(name) {
758
+ if (!name.trim()) return "Repo name is required.";
759
+ if (!FS_SAFE.test(name)) {
760
+ return "Use only letters, numbers, dots, dashes, underscores.";
761
+ }
762
+ return null;
763
+ }
764
+ function validateRemote(remote) {
765
+ if (remote.trim() === "") return null;
766
+ const ok = /^git@[^:]+:.+\.git$/.test(remote) || /^https?:\/\/.+/.test(remote) || /^ssh:\/\/.+/.test(remote);
767
+ return ok ? null : "Enter a valid git remote URL, or leave blank for local-only.";
768
+ }
769
+ function validateUniqueNames(names) {
770
+ const seen = /* @__PURE__ */ new Set();
771
+ for (const n of names) {
772
+ if (seen.has(n)) return `Duplicate repo name: ${n}`;
773
+ seen.add(n);
774
+ }
775
+ return null;
776
+ }
777
+
778
+ // src/wizard/run.ts
779
+ function cancel2(value) {
780
+ if (p.isCancel(value)) {
781
+ p.cancel("Cancelled.");
782
+ process.exit(1);
783
+ }
784
+ }
785
+ async function runWizard() {
786
+ p.intro("agentspace init");
787
+ const workspaceName = await p.text({
788
+ message: "Workspace name",
789
+ validate: (v) => validateWorkspaceName(v) ?? void 0
790
+ });
791
+ cancel2(workspaceName);
792
+ const shape = await p.select({
793
+ message: "Workspace shape",
794
+ options: [
795
+ { value: "single-repo", label: "Single repo" },
796
+ { value: "one-product", label: "Multi-repo, one product (backend + clients)" },
797
+ { value: "peer-services", label: "Multi-repo, peer services (no global order)" },
798
+ { value: "library-consumers", label: "Multi-repo, library + consumers" },
799
+ { value: "unrelated", label: "Multi-repo, unrelated" }
800
+ ]
801
+ });
802
+ cancel2(shape);
803
+ const repos = [];
804
+ let addMore = true;
805
+ while (addMore) {
806
+ let name;
807
+ while (true) {
808
+ const nameInput = await p.text({
809
+ message: `Repo #${repos.length + 1} directory name`,
810
+ validate: (v) => validateRepoName(v) ?? void 0
811
+ });
812
+ cancel2(nameInput);
813
+ const dupError = validateUniqueNames([...repos.map((r) => r.name), nameInput.trim()]);
814
+ if (dupError) {
815
+ p.log.error(dupError);
816
+ continue;
817
+ }
818
+ name = nameInput;
819
+ break;
820
+ }
821
+ const remote = await p.text({
822
+ message: "Git remote URL (blank = local-only)",
823
+ validate: (v) => validateRemote(v) ?? void 0
824
+ });
825
+ cancel2(remote);
826
+ const stack = await p.text({ message: "Stack id (or 'generic')", placeholder: "generic" });
827
+ cancel2(stack);
828
+ const role = await p.text({ message: "Role (one line)" });
829
+ cancel2(role);
830
+ repos.push({ name, remote, stack: stack || "generic", role });
831
+ if (shape === "single-repo") break;
832
+ const more = await p.confirm({ message: "Add another repo?" });
833
+ cancel2(more);
834
+ addMore = more === true;
835
+ }
836
+ let dependencyOrder = [];
837
+ if (shapeHasDependencyOrder(shape) && repos.length > 1) {
838
+ p.note(
839
+ "Dependency order: which repo defines contracts the others consume (producer first)."
840
+ );
841
+ const remaining = repos.map((r) => r.name);
842
+ while (dependencyOrder.length < remaining.length) {
843
+ const pick = await p.select({
844
+ message: `Position ${dependencyOrder.length + 1}`,
845
+ options: remaining.filter((n) => !dependencyOrder.includes(n)).map((n) => ({ value: n, label: n }))
846
+ });
847
+ cancel2(pick);
848
+ dependencyOrder.push(pick);
849
+ }
850
+ }
851
+ const enableWiki = await p.confirm({ message: "Include the memory-bank wiki?", initialValue: true });
852
+ cancel2(enableWiki);
853
+ const enableEnforcement = await p.confirm({
854
+ message: "Include the Claude Code enforcement pack (agents, Stop hook, commands)?",
855
+ initialValue: false
856
+ });
857
+ cancel2(enableEnforcement);
858
+ const enableContracts = await p.confirm({
859
+ message: "Include the cross-repo contract layer (OpenSpec)?",
860
+ initialValue: false
861
+ });
862
+ cancel2(enableContracts);
863
+ p.outro("Generating workspace\u2026");
864
+ return assembleConfig({
865
+ workspaceName,
866
+ shape,
867
+ repos,
868
+ dependencyOrder,
869
+ enableWiki: enableWiki === true,
870
+ enableEnforcement: enableEnforcement === true,
871
+ enableContracts: enableContracts === true
872
+ });
873
+ }
874
+
875
+ // src/commands/init.ts
876
+ function generateWorkspace(config, today) {
877
+ const ctx = buildContext(config, today);
878
+ const files = [];
879
+ files.push(...generateManifest(ctx.manifest));
880
+ if (config.pillars.includes("wiki")) {
881
+ files.push(...generateMemoryBank(ctx.wiki));
882
+ }
883
+ if (config.pillars.includes("enforcement") && ctx.enforcement) {
884
+ const intents = generateEnforcementIntents(ctx.enforcement);
885
+ files.push(...claudeCodeAdapter(intents, ctx.enforcement));
886
+ }
887
+ if (config.pillars.includes("contracts") && ctx.contracts) {
888
+ files.push(...generateContracts(ctx.contracts));
889
+ }
890
+ return files;
891
+ }
892
+ async function runInit(config, targetDir, opts) {
893
+ const files = generateWorkspace(config, opts.today);
894
+ await writeTree(files, targetDir, { force: opts.force });
895
+ }
896
+ async function initCommand(opts) {
897
+ const config = await runWizard();
898
+ await runInit(config, process.cwd(), opts);
899
+ console.log(`
900
+ \u2713 Created ${config.workspaceName} (${config.pillars.join(", ")}).`);
901
+ console.log(" Next: ./clone-repos.sh");
902
+ if (config.pillars.includes("contracts") && isContractLinked(config)) {
903
+ console.log(
904
+ " Contracts: run `openspec update` to install the /opsx:* commands (install the openspec CLI first if needed)."
905
+ );
906
+ }
907
+ }
908
+
909
+ // src/commands/doctor.ts
910
+ import { readFile, readdir as readdir2, stat } from "fs/promises";
911
+ import { existsSync as existsSync3 } from "fs";
912
+ import { join as join5, relative } from "path";
913
+ import { parse as parse2 } from "yaml";
914
+
915
+ // src/budgets.ts
916
+ var SIZE_BUDGETS = [
917
+ { label: "log.md", cap: 500, match: (p2) => p2 === "memory-bank/log.md" },
918
+ {
919
+ label: "01-active/currentWork.md",
920
+ cap: 150,
921
+ match: (p2) => p2 === "memory-bank/01-active/currentWork.md"
922
+ },
923
+ {
924
+ label: "00-core/*.md",
925
+ cap: 800,
926
+ match: (p2) => /^memory-bank\/00-core\/.+\.md$/.test(p2)
927
+ },
928
+ {
929
+ label: "04-business/*.md",
930
+ cap: 800,
931
+ match: (p2) => /^memory-bank\/04-business\/.+\.md$/.test(p2)
932
+ }
933
+ ];
934
+ var STALE_DAYS = 30;
935
+
936
+ // src/openspec.ts
937
+ import { execSync } from "child_process";
938
+ function openspecAvailable() {
939
+ try {
940
+ execSync("command -v openspec", { stdio: "ignore", shell: "/bin/sh" });
941
+ return true;
942
+ } catch {
943
+ return false;
944
+ }
945
+ }
946
+
947
+ // src/commands/doctor.ts
948
+ async function walk(dir) {
949
+ const out = [];
950
+ let entries = [];
951
+ try {
952
+ entries = await readdir2(dir);
953
+ } catch {
954
+ return out;
955
+ }
956
+ for (const name of entries) {
957
+ const full = join5(dir, name);
958
+ const s = await stat(full);
959
+ if (s.isDirectory()) out.push(...await walk(full));
960
+ else out.push(full);
961
+ }
962
+ return out;
963
+ }
964
+ function daysBetween(fromIso, toIso) {
965
+ const from = new Date(fromIso).getTime();
966
+ const to = new Date(toIso).getTime();
967
+ return Math.floor((to - from) / 864e5);
968
+ }
969
+ async function runChecks(workspaceDir, today, deps = {}) {
970
+ const findings = [];
971
+ const isOpenspecAvailable = deps.openspecAvailable ?? openspecAvailable;
972
+ try {
973
+ const raw = await readFile(join5(workspaceDir, "manifest.yaml"), "utf8");
974
+ const doc = parse2(raw);
975
+ if (!doc || !Array.isArray(doc.repos) || doc.repos.length === 0) {
976
+ findings.push({ level: "error", message: "manifest.yaml has no repos." });
977
+ }
978
+ } catch {
979
+ findings.push({ level: "error", message: "manifest.yaml is missing or unparseable." });
980
+ }
981
+ const mbDir = join5(workspaceDir, "memory-bank");
982
+ const files = await walk(mbDir);
983
+ for (const full of files) {
984
+ const rel = relative(workspaceDir, full).split("\\").join("/");
985
+ if (!rel.endsWith(".md")) continue;
986
+ const contents = await readFile(full, "utf8");
987
+ const lineCount = contents.split("\n").length;
988
+ const budget = SIZE_BUDGETS.find((b) => b.match(rel));
989
+ if (budget && lineCount > budget.cap) {
990
+ findings.push({
991
+ level: "warn",
992
+ message: `${rel}: ${lineCount} lines exceeds cap of ${budget.cap} (${budget.label}). Split or archive.`
993
+ });
994
+ }
995
+ const match = contents.match(/_Last verified:\s*(\d{4}-\d{2}-\d{2})/);
996
+ if (match) {
997
+ const age = daysBetween(match[1], today);
998
+ if (age > STALE_DAYS) {
999
+ findings.push({
1000
+ level: "warn",
1001
+ message: `${rel}: stale \u2014 last verified ${age} days ago (> ${STALE_DAYS}). Re-verify.`
1002
+ });
1003
+ }
1004
+ }
1005
+ }
1006
+ if (existsSync3(join5(workspaceDir, "openspec")) && !isOpenspecAvailable()) {
1007
+ findings.push({
1008
+ level: "warn",
1009
+ message: "openspec/ is present but the `openspec` CLI was not found on PATH. Install it (https://github.com/Fission-AI/OpenSpec) and run `openspec update`."
1010
+ });
1011
+ }
1012
+ return findings;
1013
+ }
1014
+ function formatLintJson(findings) {
1015
+ return JSON.stringify({ findings });
1016
+ }
1017
+ async function doctorCommand(workspaceDir, today, opts = {}) {
1018
+ const findings = await runChecks(workspaceDir, today);
1019
+ if (opts.lint) {
1020
+ console.log(formatLintJson(findings));
1021
+ } else if (findings.length === 0) {
1022
+ console.log("\xB7 No issues found.");
1023
+ } else {
1024
+ for (const f of findings) {
1025
+ const tag = f.level === "error" ? "\u2717" : f.level === "warn" ? "!" : "\xB7";
1026
+ console.log(`${tag} ${f.message}`);
1027
+ }
1028
+ }
1029
+ return findings.some((f) => f.level === "error") ? 1 : 0;
1030
+ }
1031
+
1032
+ // src/version.ts
1033
+ var VERSION = "0.3.0";
1034
+
1035
+ // src/cli.ts
1036
+ function parseArgs(argv) {
1037
+ const force = argv.includes("--force");
1038
+ const lint = argv.includes("--lint");
1039
+ const first = argv[0];
1040
+ if (first === "init") return { command: "init", force, lint };
1041
+ if (first === "doctor") return { command: "doctor", force, lint };
1042
+ if (first === "--version" || first === "-v") return { command: "version", force, lint };
1043
+ return { command: "help", force, lint };
1044
+ }
1045
+ var HELP = `agentspace \u2014 scaffold an agent-native multi-repo workspace
1046
+
1047
+ Usage:
1048
+ agentspace init [--force] Interactively scaffold a workspace in the current dir
1049
+ agentspace doctor Run mechanical health checks on a workspace
1050
+ agentspace --version Print version
1051
+ agentspace --help Show this help
1052
+ `;
1053
+ function todayIso() {
1054
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1055
+ }
1056
+ async function main(argv) {
1057
+ const args = parseArgs(argv);
1058
+ switch (args.command) {
1059
+ case "init":
1060
+ await initCommand({ force: args.force, today: todayIso() });
1061
+ return 0;
1062
+ case "doctor":
1063
+ return doctorCommand(process.cwd(), todayIso(), { lint: args.lint });
1064
+ case "version":
1065
+ console.log(VERSION);
1066
+ return 0;
1067
+ case "help":
1068
+ default:
1069
+ console.log(HELP);
1070
+ return 0;
1071
+ }
1072
+ }
1073
+ var invokedDirectly = process.argv[1] !== void 0 && import.meta.url === pathToFileURL(process.argv[1]).href;
1074
+ if (invokedDirectly) {
1075
+ main(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
1076
+ console.error(err instanceof Error ? err.message : String(err));
1077
+ process.exit(1);
1078
+ });
1079
+ }
1080
+ export {
1081
+ main,
1082
+ parseArgs
1083
+ };