@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/LICENSE +21 -0
- package/README.md +106 -0
- package/assets/memory-bank-stop.cjs +136 -0
- package/dist/cli.js +1083 -0
- package/package.json +51 -0
- package/stack-agents/_generic.md +31 -0
- package/stack-agents/django.md +34 -0
- package/stack-agents/expo.md +34 -0
- package/stack-agents/go.md +33 -0
- package/stack-agents/nextjs.md +35 -0
- package/stack-agents/rails.md +36 -0
- package/stack-agents/spring-boot.md +34 -0
- package/stack-agents/stacks.yaml +22 -0
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
|
+
};
|