@orkestrel/scaffold 0.0.55 → 0.0.56

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.
@@ -1,7 +1,10 @@
1
+ import { existsSync, readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
1
3
  import { describe, expect, it } from 'vitest'
2
4
  import {
3
5
  BRIDGE_POLICY_CONTROLS,
4
6
  createPolicyScratch,
7
+ createSkillMetadata,
5
8
  FUNCTION_SOURCE_FILES,
6
9
  GENERIC_POLICY_SOURCES,
7
10
  inspectPolicyControl,
@@ -23,6 +26,8 @@ import {
23
26
  readPolicyPaths,
24
27
  readSkillFamily,
25
28
  RULES_POLICY_CONTROLS,
29
+ SKILL_BRIDGE_ROOT,
30
+ SKILL_FAMILY_ROOT,
26
31
  SKILL_POLICY_APOSTROPHE,
27
32
  SKILL_POLICY_BACKTICKED,
28
33
  SKILL_POLICY_CONTROLS,
@@ -30,6 +35,7 @@ import {
30
35
  SKILL_POLICY_FENCED,
31
36
  SKILL_POLICY_FOLDED,
32
37
  SKILL_POLICY_PARAGRAPHS,
38
+ SKILL_POLICY_TEXT,
33
39
  stemToPolicyCandidates,
34
40
  testToPolicyStem,
35
41
  } from './setupPolicy.js'
@@ -345,10 +351,29 @@ describe('instrument negative controls', () => {
345
351
  })
346
352
 
347
353
  describe('skill family policy', () => {
348
- it('discovers a non-empty family containing orkestrel-falsify', () => {
354
+ // The family is read from the workspace it runs in, so a membership literal would
355
+ // bind this file to one workspace. The relationship binds in every workspace: a
356
+ // direct `node:fs` read of the canonical root is a second mechanism that reports
357
+ // the same directories, and reports none where the root is absent.
358
+ //
359
+ // The root is spelled here as literal segments rather than read from
360
+ // `SKILL_FAMILY_ROOT`, and that literal is what makes this read a second
361
+ // mechanism. Both sides reading the constant would move together when it drifts,
362
+ // so the case would stay green for every value the constant ever holds. Against
363
+ // the literal, a drifted constant desyncs the sides and reddens this case in a
364
+ // workspace that has the tree, while a workspace without one still passes on
365
+ // both readings being empty.
366
+ it('discovers exactly the directories the canonical skill root holds', () => {
367
+ const root = join(process.cwd(), '.agents', 'skills')
368
+ const held = existsSync(root)
369
+ ? readdirSync(root, { withFileTypes: true })
370
+ .filter((entry) => entry.isDirectory())
371
+ .map((entry) => entry.name)
372
+ .sort()
373
+ : []
349
374
  const family = readSkillFamily(process.cwd())
350
- expect(family.length).toBeGreaterThan(0)
351
- expect(family).toContain('orkestrel-falsify')
375
+ expect(family.length > 0).toBe(held.length > 0)
376
+ expect([...family]).toEqual(held)
352
377
  })
353
378
 
354
379
  it('requires every discovered skill file, metadata token, and reference', () => {
@@ -490,10 +515,52 @@ describe('repository policy', () => {
490
515
  expect(inspectPolicyWorkspace(process.cwd())).toEqual([])
491
516
  })
492
517
 
518
+ // A target reads the canon from the installed package, so its tree carries the
519
+ // pointer pair and no `.agents/` directory, no rule map, and no skill bridges.
520
+ // This vendored suite runs there, and every inspector it routes through has to
521
+ // stay silent on that shape.
522
+ it('accepts a target holding the pointer pair and no canon tree', () => {
523
+ const scratch = createPolicyScratch({ prefix: 'orkestrel-policy-pointer-' })
524
+ try {
525
+ scratch.write(
526
+ 'AGENTS.md',
527
+ '# AGENTS.md\n\nRead `node_modules/@orkestrel/scaffold/dist/host/AGENTS.md` for the canon.\n',
528
+ )
529
+ scratch.write(
530
+ 'CLAUDE.md',
531
+ '# Claude Code bridge\n\nRead the `AGENTS.md` file beside this one first.\n',
532
+ )
533
+ scratch.write('.claude/settings.json', '{\n\t"permissions": {\n\t\t"allow": []\n\t}\n}\n')
534
+ scratch.write(
535
+ '.claude/agents/orkestrel.md',
536
+ '# Orkestrel\n\nThe agent carrying the package catalog.\n',
537
+ )
538
+ scratch.write(
539
+ 'package.json',
540
+ '{\n\t"name": "target",\n\t"private": true,\n\t"scripts": {\n\t\t"test": "vitest run"\n\t}\n}\n',
541
+ )
542
+ expect(inspectPolicyWorkspace(scratch.path)).toEqual([])
543
+ // The control: the same workspace with one canonical skill planted and no
544
+ // bridge beside it reports the twin violation, so the empty result above is a
545
+ // sweep that ran rather than a sweep with nothing it could report.
546
+ scratch.write(`${SKILL_FAMILY_ROOT}/sample/SKILL.md`, SKILL_POLICY_TEXT)
547
+ scratch.write(`${SKILL_FAMILY_ROOT}/sample/agents/openai.yaml`, createSkillMetadata('sample'))
548
+ expect(inspectPolicyWorkspace(scratch.path)).toEqual([
549
+ {
550
+ rule: 'bridge',
551
+ path: `${SKILL_BRIDGE_ROOT}/sample`,
552
+ message: 'canonical skill has a matching provider bridge directory',
553
+ },
554
+ ])
555
+ } finally {
556
+ scratch.destroy()
557
+ }
558
+ })
559
+
493
560
  it('reaches every branch of the workspace-authored path population', () => {
494
561
  const paths = readPolicyPaths(process.cwd())
495
562
  expect(paths).toContain('tests/setupPolicy.ts')
496
- expect(paths).toContain('.claude/rules/names.md')
563
+ expect(paths).toContain('.claude/settings.json')
497
564
  expect(paths).toContain('package.json')
498
565
  expect(paths).toContain('.gitattributes')
499
566
  })
@@ -4,7 +4,7 @@ let _orkestrel_template = require("@orkestrel/template");
4
4
  let _orkestrel_emitter = require("@orkestrel/emitter");
5
5
  var package_default = {
6
6
  name: "@orkestrel/scaffold",
7
- version: "0.0.55",
7
+ version: "0.0.56",
8
8
  description: "Scaffold workspaces with five commands: new, audit, repair, catalog, and overwrite.",
9
9
  keywords: [
10
10
  "audit",
@@ -206,36 +206,28 @@ var BIN_CONFIGS = Object.freeze(["configs/src/vite.bin.config.ts", "configs/src/
206
206
  /** The executable entry whose presence makes a workspace `bin`. */
207
207
  var BIN_ENTRY_PATH = "src/bin/main.ts";
208
208
  /**
209
- * The paths byte-copied from the vendored data root, frozen.
209
+ * The paths a target receives from the vendored data root, frozen.
210
210
  *
211
211
  * @remarks
212
- * These are the files the fleet shares verbatim: the root instruction
213
- * documents, the licence, the canonical orchestration contract every harness
214
- * bridge points at, the harness directories, the session hook scripts,
215
- * the shared policy register, the byte-identical root dotfiles, and the
216
- * guide mirrors a generated workspace starts from. A directory entry vendors
212
+ * These are the files the fleet shares verbatim and every target holds a copy
213
+ * of: the licence, the harness permission file, the session hook scripts, the
214
+ * shared policy register, the byte-identical root dotfiles, and the guide
215
+ * mirrors a generated workspace starts from. A directory entry vendors
217
216
  * everything beneath it.
218
217
  *
219
218
  * A plan carries the subset its target selects, which is why the list is a
220
219
  * candidate set rather than a plan: a workspace never mirrors its own guide.
220
+ *
221
+ * Neither the instruction canon nor the harness wiring is here. A target reads
222
+ * its rules, its skills, its agent roles, its bench configuration, and its MCP
223
+ * registrations from {@link CANON_PATHS} inside the installed package, so no
224
+ * file scaffold leaves in a target names a path the target does not hold.
225
+ * `nameToHostArtifacts` appends {@link CATALOG_AGENT_PATH} to what this list
226
+ * selects, which is what keeps the list itself disjoint from the canon.
221
227
  */
222
228
  var HOST_PATHS = Object.freeze([
223
- "AGENTS.md",
224
- "CLAUDE.md",
225
229
  "LICENSE",
226
- ".agents/orchestration.md",
227
- ".agents/skills",
228
- ".agents/templates",
229
- ".agents/transports",
230
- ".claude/agents",
231
- ".claude/rules",
232
- ".claude/skills",
233
230
  ".claude/settings.json",
234
- ".codex/agents",
235
- ".codex/config.toml",
236
- ".cursor/mcp.json",
237
- ".cursor/rules",
238
- ".mcp.json",
239
231
  "scripts/deps.sh",
240
232
  "scripts/cursor.sh",
241
233
  "scripts/codex.sh",
@@ -255,6 +247,53 @@ var HOST_PATHS = Object.freeze([
255
247
  "guides/guide.md",
256
248
  "guides/scaffold.md"
257
249
  ]);
250
+ /**
251
+ * The instruction-canon paths staged for reading rather than for a target, frozen.
252
+ *
253
+ * @remarks
254
+ * The root instruction documents, the orchestration contract every harness
255
+ * bridge points at, the rule map's rules, the skills, the templates, the
256
+ * transport contracts, the agent roles each harness dispatches, the bench
257
+ * configuration, and the MCP registrations. A directory entry covers everything
258
+ * beneath it.
259
+ *
260
+ * Staging walks these beside {@link HOST_PATHS}, so a release ships them and a
261
+ * reader reaches them two ways: a scaffold checkout sitting beside the
262
+ * repository, or the `node_modules/@orkestrel/scaffold/dist/host/` root inside
263
+ * the installed package. The `AGENTS.md` and `CLAUDE.md` pointers scaffold plans
264
+ * are what name each location.
265
+ *
266
+ * The lists are disjoint by prefix in either direction: no member of either
267
+ * equals or sits beneath a member of the other. Staging depends on that, because
268
+ * the walk covers the union and a path it discovers twice claims one storage
269
+ * name twice, which refuses the stage.
270
+ *
271
+ * The plan claims paths inside the canon deliberately, and each has a reason.
272
+ * `blueprintToDocumentArtifacts` claims `AGENTS.md` and `CLAUDE.md` as this
273
+ * package's own template pointers. `nameToHostArtifacts` claims
274
+ * {@link CATALOG_AGENT_PATH}, because the catalog verb refuses a target that
275
+ * lacks the file and repair restores its absence.
276
+ *
277
+ * A target therefore holds a file at a canon path only where the plan claims it.
278
+ * That is the rule every verb obeys, and it is what makes a copy found anywhere
279
+ * else superseded.
280
+ */
281
+ var CANON_PATHS = Object.freeze([
282
+ "AGENTS.md",
283
+ "CLAUDE.md",
284
+ ".mcp.json",
285
+ ".agents/orchestration.md",
286
+ ".agents/skills",
287
+ ".agents/templates",
288
+ ".agents/transports",
289
+ ".claude/agents",
290
+ ".claude/rules",
291
+ ".claude/skills",
292
+ ".codex/agents",
293
+ ".codex/config.toml",
294
+ ".cursor/mcp.json",
295
+ ".cursor/rules"
296
+ ]);
258
297
  /** The repository-relative path where the committed vendored-file inventory is served. */
259
298
  var HOST_INVENTORY_PATH = "host.json";
260
299
  /**
@@ -316,9 +355,15 @@ var ORCHESTRATION_PATH_NAMES = Object.freeze([".mcp.json"]);
316
355
  * The agent file whose marker-bounded package table the catalog verb alone owns.
317
356
  *
318
357
  * @remarks
319
- * It is vendored like every other host artifact but claimed by presence rather
320
- * than content, so a consumer's own edits to the file survive every verb and
321
- * only the catalog verb rewrites the region inside the markers.
358
+ * A plan claims it at a canon path, because the catalog verb refuses a target
359
+ * that lacks the file. `nameToHostArtifacts` appends it to the vendored
360
+ * selection, and it reaches a release through the `.claude/agents` directory in
361
+ * {@link CANON_PATHS} rather than through {@link HOST_PATHS}, which is what
362
+ * keeps the two lists disjoint.
363
+ *
364
+ * It is claimed by presence rather than content, so a consumer's own edits to
365
+ * the file survive every verb and only the catalog verb rewrites the region
366
+ * inside the markers.
322
367
  */
323
368
  var CATALOG_AGENT_PATH = ".claude/agents/orkestrel.md";
324
369
  /** The provisioner skeleton a workspace with declared service vendors is given once. */
@@ -2508,7 +2553,8 @@ describe('workspace integration', () => {
2508
2553
  })
2509
2554
  `
2510
2555
  }),
2511
- docs: Object.freeze({ readme: `# {{package}}
2556
+ docs: Object.freeze({
2557
+ readme: `# {{package}}
2512
2558
 
2513
2559
  {{description}}
2514
2560
 
@@ -2518,7 +2564,44 @@ describe('workspace integration', () => {
2518
2564
  npm install
2519
2565
  npm test
2520
2566
  \`\`\`
2521
- ` }),
2567
+ `,
2568
+ agents: `# AGENTS.md
2569
+
2570
+ The \`@orkestrel/scaffold\` package is this repository's coding and orchestration authority. This
2571
+ file points at it and states no law of its own.
2572
+
2573
+ Read these before working: the \`AGENTS.md\` coding contract, the \`.agents/orchestration.md\`
2574
+ agent-operation contract, every applicable rule the contract's rule map names under
2575
+ \`.claude/rules/\`, and the dispatch-named skill under \`.agents/skills/\` with the references it
2576
+ requires.
2577
+
2578
+ Resolve every one of those paths against scaffold, never against this repository:
2579
+
2580
+ - When a scaffold checkout sits beside this repository, read \`../scaffold/AGENTS.md\`, the
2581
+ \`../scaffold/.agents/orchestration.md\` file, the \`../scaffold/.claude/rules/\` directory, and
2582
+ the \`../scaffold/.agents/skills/\` directory.
2583
+ - Otherwise read the installed copy, whose paths drop the dot that opens each segment: the
2584
+ \`node_modules/@orkestrel/scaffold/dist/host/AGENTS.md\` file, the
2585
+ \`node_modules/@orkestrel/scaffold/dist/host/agents/orchestration.md\` file, the
2586
+ \`node_modules/@orkestrel/scaffold/dist/host/claude/rules/\` directory, and the
2587
+ \`node_modules/@orkestrel/scaffold/dist/host/agents/skills/\` directory.
2588
+
2589
+ Every path a scaffold-supplied file names resolves the same way. The files this repository carries
2590
+ — the \`.claude/agents/orkestrel.md\` catalog file, the \`.claude/settings.json\` permission file,
2591
+ and the bench scripts under \`scripts/\` — are this repository's own copies and resolve here.
2592
+
2593
+ Edit none of the scaffold-owned files here. The \`scaffold repair\` command restores them, so a
2594
+ change to one is a commit in the scaffold repository followed by a release.
2595
+ `,
2596
+ claude: `# CLAUDE.md
2597
+
2598
+ Read the \`AGENTS.md\` file in this repository first. It names the coding and orchestration
2599
+ authority and where to read each contract.
2600
+
2601
+ This file imports nothing. An \`@path\` import inlines the imported file into every context that
2602
+ loads it, which is the cost this pointer removes.
2603
+ `
2604
+ }),
2522
2605
  guides: Object.freeze({ readme: `# Guides
2523
2606
 
2524
2607
  ## By concept
@@ -3375,6 +3458,35 @@ function isDeferredPath(path) {
3375
3458
  return path === ".claude/agents/orkestrel.md" || path.startsWith("guides/") && path.endsWith(".md");
3376
3459
  }
3377
3460
  /**
3461
+ * Checks whether a path belongs to the instruction canon a target reads rather than holds.
3462
+ *
3463
+ * @param path - The target-relative path to test.
3464
+ * @returns `true` for a {@link CANON_PATHS} member and for any path beneath a
3465
+ * member that is a directory; `false` otherwise.
3466
+ *
3467
+ * @remarks
3468
+ * The one reading of canon membership, so the live overlay and the executable's
3469
+ * fetch list never disagree about what a path is. The match runs to a segment
3470
+ * boundary, so a sibling whose name opens with a member's name —
3471
+ * `.claude/rulesets` beside `.claude/rules` — stays outside.
3472
+ *
3473
+ * Membership answers where a path's bytes are staged, not whether a plan claims
3474
+ * it. A plan claims `AGENTS.md`, `CLAUDE.md`, and {@link CATALOG_AGENT_PATH} at
3475
+ * canon paths deliberately, so a consumer deciding whether to write, restore, or
3476
+ * remove a path reads the plan rather than this predicate.
3477
+ *
3478
+ * @example
3479
+ * ```ts
3480
+ * import { isCanonPath } from '@orkestrel/scaffold'
3481
+ *
3482
+ * isCanonPath('.claude/rules/names.md') // true
3483
+ * isCanonPath('.claude/settings.json') // false
3484
+ * ```
3485
+ */
3486
+ function isCanonPath(path) {
3487
+ return CANON_PATHS.some((canon) => path === canon || path.startsWith(`${canon}/`));
3488
+ }
3489
+ /**
3378
3490
  * Infer the {@link Group} a path belongs to.
3379
3491
  *
3380
3492
  * @param path - The target-relative path to classify.
@@ -5137,22 +5249,53 @@ function blueprintToGuideArtifacts(blueprint) {
5137
5249
  * Compile the generated workspace's root documentation.
5138
5250
  *
5139
5251
  * @param blueprint - The workspace specification.
5140
- * @returns One birth-owned package front page.
5252
+ * @returns The birth-owned package front page and the content-owned `AGENTS.md`
5253
+ * and `CLAUDE.md` pointers.
5254
+ *
5255
+ * @remarks
5256
+ * The front page is the workspace's own prose, so it is written once and left
5257
+ * alone from then on. The pointers are scaffold's, so they are content-owned and
5258
+ * restored whenever they drift.
5259
+ *
5260
+ * A pointer is planned here rather than vendored because `stageHost` refuses two
5261
+ * vendored paths at one storage name, and `AGENTS.md` and `CLAUDE.md` already
5262
+ * store the canon a release ships. Planning them as this package's own content
5263
+ * leaves each path with one claimant.
5264
+ *
5265
+ * Neither pointer carries a varying span, so neither is filled: a workspace's
5266
+ * name never reaches the text, and the paths a reader follows are the same in
5267
+ * every target.
5141
5268
  */
5142
5269
  function blueprintToDocumentArtifacts(blueprint) {
5143
5270
  const publishes = blueprint.src.length > 0;
5144
5271
  const name = publishes ? `@orkestrel/${blueprint.name}` : blueprint.name;
5145
5272
  const description = blueprint.description ?? (publishes ? `The ${name} package.` : `The ${name} application.`);
5146
- return [{
5147
- path: "README.md",
5148
- group: "docs",
5149
- ownership: "birth",
5150
- origin: "template",
5151
- content: (0, _orkestrel_template.fillTemplate)(ARTIFACT_TEMPLATES.docs.readme, {
5152
- package: name,
5153
- description
5154
- })
5155
- }];
5273
+ return [
5274
+ {
5275
+ path: "README.md",
5276
+ group: "docs",
5277
+ ownership: "birth",
5278
+ origin: "template",
5279
+ content: (0, _orkestrel_template.fillTemplate)(ARTIFACT_TEMPLATES.docs.readme, {
5280
+ package: name,
5281
+ description
5282
+ })
5283
+ },
5284
+ {
5285
+ path: "AGENTS.md",
5286
+ group: "docs",
5287
+ ownership: "content",
5288
+ origin: "template",
5289
+ content: ARTIFACT_TEMPLATES.docs.agents
5290
+ },
5291
+ {
5292
+ path: "CLAUDE.md",
5293
+ group: "docs",
5294
+ ownership: "content",
5295
+ origin: "template",
5296
+ content: ARTIFACT_TEMPLATES.docs.claude
5297
+ }
5298
+ ];
5156
5299
  }
5157
5300
  /**
5158
5301
  * Compile the blueprint-dependent orchestration artifacts.
@@ -5180,7 +5323,8 @@ function blueprintToOrchestrationArtifacts(blueprint) {
5180
5323
  * Compile the vendored host artifacts a named workspace plans.
5181
5324
  *
5182
5325
  * @param name - The target workspace's own bare package name.
5183
- * @returns One artifact per vendored path, in `HOST_PATHS` order.
5326
+ * @returns One artifact per vendored path in `HOST_PATHS` order, then the
5327
+ * catalog file.
5184
5328
  *
5185
5329
  * @remarks
5186
5330
  * Every artifact is claimed by presence, which is the strongest claim a pure
@@ -5194,16 +5338,26 @@ function blueprintToOrchestrationArtifacts(blueprint) {
5194
5338
  * are classified by one rule and a plan never disagrees with the audit beside
5195
5339
  * it.
5196
5340
  *
5341
+ * {@link CATALOG_AGENT_PATH} is appended rather than listed, and it is the canon
5342
+ * path this compiler claims. The catalog verb refuses a target that lacks the
5343
+ * file, so the plan has to carry it; repair restores its absence from the
5344
+ * staged bytes; and the `.claude/agents` directory in `CANON_PATHS` is what
5345
+ * stages those bytes, so listing the file in `HOST_PATHS` as well would claim one
5346
+ * storage name twice and refuse the stage. The rest of the canon a target reads
5347
+ * from the installed package, at the locations the `AGENTS.md` and `CLAUDE.md`
5348
+ * pointers {@link blueprintToDocumentArtifacts} emits name.
5349
+ *
5197
5350
  * @example
5198
5351
  * ```ts
5199
5352
  * import { nameToHostArtifacts } from '@orkestrel/scaffold'
5200
5353
  *
5201
- * nameToHostArtifacts('router').some((artifact) => artifact.path === 'AGENTS.md') // true
5354
+ * nameToHostArtifacts('router').some((artifact) => artifact.path === '.claude/settings.json') // true
5355
+ * nameToHostArtifacts('router').some((artifact) => artifact.path === '.claude/agents/orkestrel.md') // true
5202
5356
  * nameToHostArtifacts('router').some((artifact) => artifact.path === 'guides/router.md') // false
5203
5357
  * ```
5204
5358
  */
5205
5359
  function nameToHostArtifacts(name) {
5206
- return selectHostPaths(HOST_PATHS, name).map((path) => ({
5360
+ return [...selectHostPaths(HOST_PATHS, name), CATALOG_AGENT_PATH].map((path) => ({
5207
5361
  path,
5208
5362
  group: inferGroup(path),
5209
5363
  ownership: "presence",
@@ -6535,6 +6689,7 @@ exports.ARTIFACT_TEMPLATES = ARTIFACT_TEMPLATES;
6535
6689
  exports.BASE_DEV_DEPENDENCIES = BASE_DEV_DEPENDENCIES;
6536
6690
  exports.BIN_CONFIGS = BIN_CONFIGS;
6537
6691
  exports.BIN_ENTRY_PATH = BIN_ENTRY_PATH;
6692
+ exports.CANON_PATHS = CANON_PATHS;
6538
6693
  exports.CATALOG_AGENT_PATH = CATALOG_AGENT_PATH;
6539
6694
  exports.CONFIG_TEMPLATES = CONFIG_TEMPLATES;
6540
6695
  exports.CONFORMANCE_TEST_PATH = CONFORMANCE_TEST_PATH;
@@ -6625,6 +6780,7 @@ exports.inferGroup = inferGroup;
6625
6780
  exports.isArtifact = isArtifact;
6626
6781
  exports.isAudit = isAudit;
6627
6782
  exports.isBlueprint = isBlueprint;
6783
+ exports.isCanonPath = isCanonPath;
6628
6784
  exports.isCatalogEntry = isCatalogEntry;
6629
6785
  exports.isCollection = isCollection;
6630
6786
  exports.isCompilerHooks = isCompilerHooks;