@webpresso/agent-kit 0.21.5 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +87 -124
  4. package/bin/_run.js +143 -1
  5. package/bin/runtime-manifest.json +40 -0
  6. package/catalog/AGENTS.md.tpl +7 -6
  7. package/catalog/agent/commands/plan-refine.md +3 -3
  8. package/catalog/agent/commands/pll.md +2 -0
  9. package/catalog/agent/guides/parallel-execution.md +2 -0
  10. package/catalog/agent/rules/extraction-parity.md +27 -1
  11. package/catalog/agent/rules/public-package-safety.md +24 -1
  12. package/catalog/agent/skills/pll/SKILL.md +1 -0
  13. package/catalog/base-kit/.github/workflows/ci.webpresso.yml.tmpl +33 -0
  14. package/catalog/base-kit/stryker.config.ts.tmpl +2 -2
  15. package/catalog/docs/templates/blueprint.md +1 -0
  16. package/catalog/docs/templates/blueprint.yaml +10 -12
  17. package/commands/blueprint.md +8 -43
  18. package/dist/esm/audit/blueprint-db-consistency.d.ts +1 -1
  19. package/dist/esm/audit/blueprint-db-consistency.js +6 -8
  20. package/dist/esm/audit/blueprint-lifecycle-sql.js +10 -3
  21. package/dist/esm/audit/cloudflare-deploy-contract.d.ts +3 -0
  22. package/dist/esm/audit/cloudflare-deploy-contract.js +80 -0
  23. package/dist/esm/audit/no-legacy-cli-bin.d.ts +3 -0
  24. package/dist/esm/audit/no-legacy-cli-bin.js +100 -0
  25. package/dist/esm/audit/package-surface.js +14 -1
  26. package/dist/esm/audit/repo-guardrails.js +40 -13
  27. package/dist/esm/audit/roadmap-links.js +23 -10
  28. package/dist/esm/blueprint/core/schema.d.ts +8 -8
  29. package/dist/esm/blueprint/core/schema.js +2 -2
  30. package/dist/esm/blueprint/db/enums.d.ts +1 -1
  31. package/dist/esm/blueprint/db/ingester.js +18 -10
  32. package/dist/esm/blueprint/lifecycle/audit.js +9 -2
  33. package/dist/esm/blueprint/lifecycle/local.js +15 -4
  34. package/dist/esm/blueprint/service/BlueprintCreationService.js +11 -6
  35. package/dist/esm/blueprint/service/BlueprintService.js +37 -19
  36. package/dist/esm/blueprint/service/scanner.js +73 -9
  37. package/dist/esm/blueprint/tracked-document/schema.d.ts +2 -2
  38. package/dist/esm/blueprint/utils/document-paths.d.ts +23 -0
  39. package/dist/esm/blueprint/utils/document-paths.js +91 -0
  40. package/dist/esm/build/package-manifest.js +7 -0
  41. package/dist/esm/build/release-policy.d.ts +27 -0
  42. package/dist/esm/build/release-policy.js +29 -0
  43. package/dist/esm/build/runtime-targets.d.ts +13 -0
  44. package/dist/esm/build/runtime-targets.js +48 -0
  45. package/dist/esm/cli/auto-update/detect-pm.d.ts +15 -0
  46. package/dist/esm/cli/auto-update/detect-pm.js +24 -9
  47. package/dist/esm/cli/auto-update/skip.js +9 -1
  48. package/dist/esm/cli/bundle/agent-command-inventory.d.ts +120 -0
  49. package/dist/esm/cli/bundle/agent-command-inventory.js +100 -0
  50. package/dist/esm/cli/bundle/index.d.ts +17 -0
  51. package/dist/esm/cli/bundle/index.js +15 -0
  52. package/dist/esm/cli/cli.d.ts +1 -1
  53. package/dist/esm/cli/cli.js +49 -5
  54. package/dist/esm/cli/commands/audit-core.d.ts +1 -1
  55. package/dist/esm/cli/commands/audit.js +2 -0
  56. package/dist/esm/cli/commands/blueprint/router.js +11 -8
  57. package/dist/esm/cli/commands/hook.d.ts +8 -0
  58. package/dist/esm/cli/commands/hook.js +47 -0
  59. package/dist/esm/cli/commands/init/index.js +35 -1
  60. package/dist/esm/cli/commands/init/scaffold-base-kit.js +1 -1
  61. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/codex-ownership.js +9 -1
  62. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.js +130 -20
  63. package/dist/esm/cli/commands/init/scaffolders/agent-kit-global/index.d.ts +65 -0
  64. package/dist/esm/cli/commands/init/scaffolders/agent-kit-global/index.js +64 -0
  65. package/dist/esm/cli/commands/package-manager.d.ts +15 -0
  66. package/dist/esm/cli/commands/package-manager.js +42 -0
  67. package/dist/esm/cli/commands/test.d.ts +1 -0
  68. package/dist/esm/cli/commands/test.js +2 -1
  69. package/dist/esm/cli/commands/typecheck.js +5 -20
  70. package/dist/esm/cli/package-scripts.d.ts +12 -0
  71. package/dist/esm/cli/package-scripts.js +59 -0
  72. package/dist/esm/cli/utils.js +3 -22
  73. package/dist/esm/cli/wp-extensions.d.ts +14 -0
  74. package/dist/esm/cli/wp-extensions.js +34 -0
  75. package/dist/esm/config/docs-lint/schemas/common.d.ts +1 -1
  76. package/dist/esm/config/docs-lint/schemas/implementation-plan.d.ts +2 -2
  77. package/dist/esm/config/docs-lint/schemas/parent-roadmap.d.ts +1 -1
  78. package/dist/esm/config/stryker/index.d.ts +85 -0
  79. package/dist/esm/config/stryker/index.js +31 -0
  80. package/dist/esm/e2e/command-builder.js +11 -2
  81. package/dist/esm/e2e/config.d.ts +65 -0
  82. package/dist/esm/e2e/config.js +126 -0
  83. package/dist/esm/e2e/execution.js +4 -0
  84. package/dist/esm/e2e/load-host-adapter.d.ts +6 -1
  85. package/dist/esm/e2e/load-host-adapter.js +27 -9
  86. package/dist/esm/e2e/run-planner.js +1 -0
  87. package/dist/esm/e2e/types.d.ts +2 -0
  88. package/dist/esm/format/index.js +1 -3
  89. package/dist/esm/hooks/guard-switch/index.d.ts +1 -1
  90. package/dist/esm/hooks/guard-switch/index.js +22 -14
  91. package/dist/esm/hooks/post-tool/lint-after-edit.d.ts +1 -0
  92. package/dist/esm/hooks/post-tool/lint-after-edit.js +5 -2
  93. package/dist/esm/hooks/pretool-guard/validators/file-conventions.js +1 -1
  94. package/dist/esm/hooks/pretool-guard/validators/forbidden-commands.d.ts +6 -0
  95. package/dist/esm/hooks/pretool-guard/validators/forbidden-commands.js +27 -2
  96. package/dist/esm/hooks/pretool-guard/validators/path-contract.d.ts +2 -1
  97. package/dist/esm/hooks/pretool-guard/validators/path-contract.js +59 -34
  98. package/dist/esm/hooks/pretool-guard/validators/plan-frontmatter.js +3 -3
  99. package/dist/esm/hooks/shared/routing-block.js +18 -4
  100. package/dist/esm/hooks/shared/validators/blueprint.js +3 -0
  101. package/dist/esm/hooks/stop/qa-changed-files.d.ts +1 -0
  102. package/dist/esm/hooks/stop/qa-changed-files.js +5 -2
  103. package/dist/esm/lint/index.js +1 -1
  104. package/dist/esm/mcp/auto-discover.d.ts +2 -0
  105. package/dist/esm/mcp/auto-discover.js +14 -6
  106. package/dist/esm/mcp/blueprint-server.js +30 -26
  107. package/dist/esm/mcp/cli.js +21 -0
  108. package/dist/esm/mcp/runners/test.js +15 -0
  109. package/dist/esm/mcp/server.d.ts +7 -0
  110. package/dist/esm/mcp/server.js +16 -27
  111. package/dist/esm/mcp/tools/_registry.d.ts +3 -0
  112. package/dist/esm/mcp/tools/_registry.js +21 -0
  113. package/dist/esm/mcp/tools/audit.d.ts +1 -0
  114. package/dist/esm/mcp/tools/audit.js +11 -0
  115. package/dist/esm/mcp/tools/e2e.d.ts +1 -1
  116. package/dist/esm/mcp/tools/typecheck.js +4 -2
  117. package/dist/esm/mutation/affected.d.ts +9 -0
  118. package/dist/esm/mutation/affected.js +36 -0
  119. package/dist/esm/package.json +5 -0
  120. package/dist/esm/runtime/package-version.d.ts +2 -0
  121. package/dist/esm/runtime/package-version.js +43 -0
  122. package/dist/esm/test/command-builder.d.ts +3 -0
  123. package/dist/esm/test/command-builder.js +22 -3
  124. package/dist/esm/tool-runtime/index.d.ts +2 -2
  125. package/dist/esm/tool-runtime/index.js +2 -1
  126. package/dist/esm/tool-runtime/resolve-runner.d.ts +3 -0
  127. package/dist/esm/tool-runtime/resolve-runner.js +7 -5
  128. package/dist/esm/typecheck/index.js +4 -2
  129. package/dist/esm/wp-extension/index.d.ts +50 -0
  130. package/dist/esm/wp-extension/index.js +268 -0
  131. package/package.json +67 -31
  132. package/skills/pll/SKILL.md +1 -0
@@ -1,6 +1,7 @@
1
1
  import matter from 'gray-matter';
2
2
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
3
  import { join, relative, resolve, sep } from 'node:path';
4
+ import { BLUEPRINT_OVERVIEW_FILENAME, isBlueprintSupportingMarkdownRelativePath, parseBlueprintDocumentRelativePath, } from '#utils/document-paths.js';
4
5
  const BLUEPRINT_STATUSES = [
5
6
  'draft',
6
7
  'planned',
@@ -11,7 +12,7 @@ const BLUEPRINT_STATUSES = [
11
12
  ];
12
13
  const BLUEPRINT_STATUS_PATTERN = BLUEPRINT_STATUSES.join('|');
13
14
  const ACTIVE_BLUEPRINT_STATUSES = new Set(['draft', 'planned', 'in-progress', 'parked']);
14
- const LOCAL_BLUEPRINT_REFERENCE_PATTERN = new RegExp(String.raw `^(?:blueprints/)?(?:${BLUEPRINT_STATUS_PATTERN})/[A-Za-z0-9._-]+(?:/_overview\.md)?$`);
15
+ const LOCAL_BLUEPRINT_REFERENCE_PATTERN = new RegExp(String.raw `^(?:blueprints/)?(?:${BLUEPRINT_STATUS_PATTERN})/[A-Za-z0-9._-]+(?:\.md|/_overview\.md)?$`);
15
16
  const GITHUB_URL_PATTERN = /https?:\/\/github\.com\//i;
16
17
  const ABSOLUTE_FILE_REFERENCE_PATTERN = /(?:^|[\s(])(?:\/|[A-Za-z]:[\\/]|file:\/\/)/i;
17
18
  const LEGACY_CROSS_REPO_LABEL_PATTERN = /cross-repo:/i;
@@ -112,22 +113,32 @@ function readBlueprintRecords(root, blueprintsRoot) {
112
113
  if (!existsSync(statusRoot))
113
114
  continue;
114
115
  for (const entry of readdirSync(statusRoot, { withFileTypes: true })) {
115
- if (!entry.isDirectory())
116
+ const canonicalPath = entry.isDirectory()
117
+ ? join(statusRoot, entry.name, BLUEPRINT_OVERVIEW_FILENAME)
118
+ : entry.isFile() && entry.name.endsWith('.md')
119
+ ? join(statusRoot, entry.name)
120
+ : null;
121
+ if (!canonicalPath || !existsSync(canonicalPath))
116
122
  continue;
117
- const overviewPath = join(statusRoot, entry.name, '_overview.md');
118
- if (!existsSync(overviewPath))
123
+ const relativeBlueprintPath = relative(blueprintsRoot, canonicalPath);
124
+ if (!parseBlueprintDocumentRelativePath(relativeBlueprintPath) ||
125
+ isBlueprintSupportingMarkdownRelativePath(relativeBlueprintPath)) {
119
126
  continue;
120
- const raw = readFileSync(overviewPath, 'utf8');
127
+ }
128
+ const raw = readFileSync(canonicalPath, 'utf8');
121
129
  const data = matter(raw).data;
122
130
  const type = data.type === 'parent-roadmap' ? 'parent-roadmap' : 'blueprint';
123
131
  const parentRoadmap = typeof data.parent_roadmap === 'string' && data.parent_roadmap.trim()
124
132
  ? data.parent_roadmap.trim()
125
133
  : undefined;
126
- const key = `${status}/${entry.name}`;
134
+ const parsedPath = parseBlueprintDocumentRelativePath(relativeBlueprintPath);
135
+ if (!parsedPath)
136
+ continue;
137
+ const key = `${status}/${parsedPath.slug}`;
127
138
  records.push({
128
- file: relativePath(root, overviewPath),
139
+ file: relativePath(root, canonicalPath),
129
140
  key,
130
- name: entry.name,
141
+ name: parsedPath.slug,
131
142
  ...(parentRoadmap ? { parentRoadmap } : {}),
132
143
  raw,
133
144
  slug: key,
@@ -146,16 +157,18 @@ function indexBlueprints(records) {
146
157
  byKey.set(record.name, record);
147
158
  byKey.set(`blueprints/${record.key}`, record);
148
159
  byKey.set(`blueprints/${record.key}/_overview.md`, record);
160
+ byKey.set(`blueprints/${record.key}.md`, record);
149
161
  byKey.set(`${record.key}/_overview.md`, record);
162
+ byKey.set(`${record.key}.md`, record);
150
163
  }
151
164
  return byKey;
152
165
  }
153
166
  function extractWaveMapChildren(markdown) {
154
167
  const refs = new Set();
155
- const pathPattern = new RegExp(String.raw `(?:blueprints/)?(${BLUEPRINT_STATUS_PATTERN})/([A-Za-z0-9._-]+)(?:/_overview\.md)?`, 'g');
168
+ const pathPattern = new RegExp(String.raw `(?:blueprints/)?(${BLUEPRINT_STATUS_PATTERN})/([A-Za-z0-9._-]+)(?:\.md|/_overview\.md)?`, 'g');
156
169
  for (const match of markdown.matchAll(pathPattern)) {
157
170
  const status = match[1];
158
- const slug = match[2];
171
+ const slug = match[2]?.replace(/\.md$/, '');
159
172
  if (!status || !slug)
160
173
  continue;
161
174
  refs.add(`${status}/${slug}`);
@@ -19,8 +19,8 @@ export declare const planStatusSchema: z.ZodEnum<{
19
19
  completed: "completed";
20
20
  draft: "draft";
21
21
  planned: "planned";
22
- "in-progress": "in-progress";
23
22
  parked: "parked";
23
+ "in-progress": "in-progress";
24
24
  archived: "archived";
25
25
  }>;
26
26
  /**
@@ -30,8 +30,8 @@ export declare const lifecycleBlueprintStatusSchema: z.ZodEnum<{
30
30
  completed: "completed";
31
31
  draft: "draft";
32
32
  planned: "planned";
33
- "in-progress": "in-progress";
34
33
  parked: "parked";
34
+ "in-progress": "in-progress";
35
35
  archived: "archived";
36
36
  }>;
37
37
  /**
@@ -71,8 +71,8 @@ export declare const crossRepoDependencySchema: z.ZodObject<{
71
71
  completed: "completed";
72
72
  draft: "draft";
73
73
  planned: "planned";
74
- "in-progress": "in-progress";
75
74
  parked: "parked";
75
+ "in-progress": "in-progress";
76
76
  archived: "archived";
77
77
  }>>;
78
78
  }, z.core.$strip>;
@@ -82,7 +82,7 @@ export declare const crossRepoDependencySchema: z.ZodObject<{
82
82
  * Required fields:
83
83
  * - type: `blueprint` or `parent-roadmap`
84
84
  * - status: Current plan status
85
- * - complexity: Estimated effort using t-shirt sizing
85
+ * - complexity: Estimated effort using t-shirt sizing (defaults to `M` when omitted for legacy blueprints)
86
86
  *
87
87
  * Optional fields:
88
88
  * - last_updated: Date plan was last modified (YYYY-MM-DD)
@@ -101,17 +101,17 @@ export declare const planFrontmatterSchema: z.ZodObject<{
101
101
  completed: "completed";
102
102
  draft: "draft";
103
103
  planned: "planned";
104
- "in-progress": "in-progress";
105
104
  parked: "parked";
105
+ "in-progress": "in-progress";
106
106
  archived: "archived";
107
107
  }>;
108
- complexity: z.ZodEnum<{
108
+ complexity: z.ZodDefault<z.ZodEnum<{
109
109
  XS: "XS";
110
110
  S: "S";
111
111
  M: "M";
112
112
  L: "L";
113
113
  XL: "XL";
114
- }>;
114
+ }>>;
115
115
  last_updated: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodDate]>>;
116
116
  created: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodDate]>>;
117
117
  progress: z.ZodOptional<z.ZodString>;
@@ -146,8 +146,8 @@ export declare const planFrontmatterSchema: z.ZodObject<{
146
146
  completed: "completed";
147
147
  draft: "draft";
148
148
  planned: "planned";
149
- "in-progress": "in-progress";
150
149
  parked: "parked";
150
+ "in-progress": "in-progress";
151
151
  archived: "archived";
152
152
  }>>;
153
153
  }, z.core.$strip>>>;
@@ -64,7 +64,7 @@ export const crossRepoDependencySchema = z.object({
64
64
  * Required fields:
65
65
  * - type: `blueprint` or `parent-roadmap`
66
66
  * - status: Current plan status
67
- * - complexity: Estimated effort using t-shirt sizing
67
+ * - complexity: Estimated effort using t-shirt sizing (defaults to `M` when omitted for legacy blueprints)
68
68
  *
69
69
  * Optional fields:
70
70
  * - last_updated: Date plan was last modified (YYYY-MM-DD)
@@ -77,7 +77,7 @@ export const planFrontmatterSchema = z.object({
77
77
  title: z.string().optional(),
78
78
  description: z.string().optional(),
79
79
  status: planStatusSchema,
80
- complexity: complexitySchema,
80
+ complexity: complexitySchema.default('M'),
81
81
  last_updated: z.union([z.string(), z.date()]).optional(),
82
82
  created: z.union([z.string(), z.date()]).optional(),
83
83
  progress: z.string().optional(),
@@ -13,8 +13,8 @@ export declare const blueprintStatusSchema: z.ZodEnum<{
13
13
  completed: "completed";
14
14
  draft: "draft";
15
15
  planned: "planned";
16
- "in-progress": "in-progress";
17
16
  parked: "parked";
17
+ "in-progress": "in-progress";
18
18
  archived: "archived";
19
19
  }>;
20
20
  export declare const blueprintComplexitySchema: z.ZodEnum<{
@@ -2,18 +2,24 @@ import { createHash } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { Database } from '#db/sqlite.js';
5
- import { glob } from 'glob';
6
5
  import { parseBlueprintForDb } from './parser/blueprint-db-parser.js';
7
6
  import { parseTechDebtForDb } from './parser/tech-debt-db-parser.js';
8
7
  import { resolvesCrossRepo } from '#cross-repo/resolver.js';
8
+ import { scanBlueprintDirectory } from '#service/scanner.js';
9
9
  import { resolveBlueprintRoot } from '#utils/blueprint-root.js';
10
+ import { parseBlueprintDocumentRelativePath } from '#utils/document-paths.js';
10
11
  import { resolveTechDebtRoot } from '#utils/tech-debt-root.js';
12
+ import { glob } from 'glob';
11
13
  // ---------------------------------------------------------------------------
12
14
  // Helpers
13
15
  // ---------------------------------------------------------------------------
14
- function deriveSlugFromBlueprintPath(filePath) {
15
- // blueprints/<status>/<slug>/_overview.md → slug is the grandparent dir name
16
- return path.basename(path.dirname(filePath));
16
+ function deriveSlugFromBlueprintPath(filePath, blueprintRoot) {
17
+ const relativePath = path.relative(blueprintRoot, filePath);
18
+ const parsed = parseBlueprintDocumentRelativePath(relativePath);
19
+ if (!parsed) {
20
+ throw new Error(`Not a canonical blueprint document: ${filePath}`);
21
+ }
22
+ return parsed.slug;
17
23
  }
18
24
  function deriveSlugFromTechDebtPath(filePath) {
19
25
  // tech-debt/<status>/h-NNN-slug.md → slug is the basename without extension
@@ -47,9 +53,9 @@ function isAllowedCrossOrg(db, sourceOrg, targetOrg) {
47
53
  // ---------------------------------------------------------------------------
48
54
  // Blueprint ingester
49
55
  // ---------------------------------------------------------------------------
50
- function upsertBlueprint(db, filePath, _cwd) {
56
+ function upsertBlueprint(db, filePath, blueprintRoot) {
51
57
  const content = readFileSync(filePath, 'utf8');
52
- const slug = deriveSlugFromBlueprintPath(filePath);
58
+ const slug = deriveSlugFromBlueprintPath(filePath, blueprintRoot);
53
59
  const parsed = parseBlueprintForDb(content, filePath, slug);
54
60
  const now = Date.now();
55
61
  const upsertBp = db.prepare(`INSERT INTO blueprints
@@ -197,18 +203,20 @@ export async function ingestBlueprints(opts) {
197
203
  const errors = [];
198
204
  let ingested = 0;
199
205
  const blueprintRoot = resolveBlueprintRoot(cwd);
200
- const pattern = path.join(blueprintRoot, '**', '_overview.md').replace(/\\/g, '/');
201
- const files = await glob(pattern, { absolute: true, nodir: true });
206
+ const files = scanBlueprintDirectory({
207
+ baseDir: blueprintRoot,
208
+ includeSpecialFolders: true,
209
+ }).map((entry) => entry.path);
202
210
  for (const filePath of files) {
203
211
  try {
204
212
  const content = readFileSync(filePath, 'utf8');
205
- const slug = deriveSlugFromBlueprintPath(filePath);
213
+ const slug = deriveSlugFromBlueprintPath(filePath, blueprintRoot);
206
214
  const newHash = createHash('sha256').update(content).digest('hex');
207
215
  if (!dryRun) {
208
216
  const existing = existingBlueprintHash(db, slug);
209
217
  if (existing === newHash)
210
218
  continue;
211
- upsertBlueprint(db, filePath, cwd);
219
+ upsertBlueprint(db, filePath, blueprintRoot);
212
220
  }
213
221
  ingested++;
214
222
  }
@@ -8,11 +8,18 @@ import { readBlueprintExecutionMetadata } from '#execution/metadata';
8
8
  import { BlueprintService } from '#service/BlueprintService';
9
9
  import { scanBlueprintDirectory } from '#service/scanner';
10
10
  import { resolveBlueprintRoot } from '#utils/blueprint-root';
11
+ import { parseBlueprintDocumentRelativePath } from '#utils/document-paths.js';
11
12
  import { relativeBlueprintSlug } from './local.js';
12
13
  function isBlueprintOverview(file) {
13
14
  const normalized = file.replace(/\\/g, '/');
14
- return (normalized.endsWith('/_overview.md') &&
15
- (normalized.includes('webpresso/blueprints/') || normalized.includes('blueprints/')));
15
+ const roots = ['webpresso/blueprints/', 'blueprints/'];
16
+ for (const root of roots) {
17
+ const index = normalized.indexOf(root);
18
+ if (index === -1)
19
+ continue;
20
+ return parseBlueprintDocumentRelativePath(normalized.slice(index + root.length)) !== null;
21
+ }
22
+ return false;
16
23
  }
17
24
  function normalizePath(file) {
18
25
  return file.replace(/\\/g, '/');
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { applyBlueprintLifecycle } from '#lifecycle/engine';
4
4
  import { scanBlueprintDirectory } from '#service/scanner';
5
5
  import { resolveBlueprintRoot } from '#utils/blueprint-root';
6
+ import { getBlueprintDocumentPaths } from '#utils/document-paths.js';
6
7
  const BLUEPRINT_SLUG_SEGMENT_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
8
  function isStatusSegment(segment) {
8
9
  return (segment === 'draft' ||
@@ -73,10 +74,20 @@ export async function applyBlueprintLifecycleToFile(projectRoot, slug, intent) {
73
74
  const location = await resolveBlueprintFile(projectRoot, slug);
74
75
  const raw = await readFile(location.path, 'utf-8');
75
76
  const mutation = applyBlueprintLifecycle(raw, location.slug, intent);
77
+ const relativeSlug = relativeBlueprintSlug(location.slug);
78
+ const isFlatFile = path.basename(location.path) !== '_overview.md';
76
79
  const sourceDir = path.dirname(location.path);
77
- const targetDir = path.join(baseDir, mutation.targetStatus, relativeBlueprintSlug(location.slug));
78
- const targetPath = path.join(targetDir, '_overview.md');
79
- if (sourceDir !== targetDir) {
80
+ const targetDocumentPaths = getBlueprintDocumentPaths(baseDir, mutation.targetStatus, relativeSlug);
81
+ const targetDir = targetDocumentPaths.directory;
82
+ const targetPath = isFlatFile ? targetDocumentPaths.flat : targetDocumentPaths.folder;
83
+ if (isFlatFile) {
84
+ if (location.path !== targetPath) {
85
+ await mkdir(path.dirname(targetPath), { recursive: true });
86
+ await rename(location.path, targetPath);
87
+ await tryRemoveEmptyParent(sourceDir);
88
+ }
89
+ }
90
+ else if (sourceDir !== targetDir) {
80
91
  await mkdir(path.dirname(targetDir), { recursive: true });
81
92
  await rename(sourceDir, targetDir);
82
93
  await tryRemoveEmptyParent(path.dirname(sourceDir));
@@ -84,7 +95,7 @@ export async function applyBlueprintLifecycleToFile(projectRoot, slug, intent) {
84
95
  await writeFile(targetPath, mutation.markdown, 'utf-8');
85
96
  return {
86
97
  ...mutation,
87
- moved: sourceDir !== targetDir,
98
+ moved: isFlatFile ? location.path !== targetPath : sourceDir !== targetDir,
88
99
  path: targetPath,
89
100
  slug: location.slug,
90
101
  };
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { parseBlueprint } from '#core/parser';
5
5
  import { scanBlueprintDirectory } from '#service/scanner';
6
6
  import { resolveBlueprintRoot } from '#utils/blueprint-root';
7
+ import { getBlueprintDocumentPaths } from '#utils/document-paths.js';
7
8
  import { resolvePackageAssetPreferred } from '#utils/package-assets';
8
9
  const RESERVED_BLUEPRINT_SLUGS = new Set([
9
10
  'draft',
@@ -167,7 +168,7 @@ export class BlueprintCreationService {
167
168
  assertGoalProducesUsableSlug(goal, baseSlug);
168
169
  const slug = this.resolveCollisionSafeSlug(baseSlug);
169
170
  const title = sentenceCase(goal);
170
- const outputPath = path.join(this.blueprintsRoot, 'draft', slug, '_overview.md');
171
+ const outputPath = getBlueprintDocumentPaths(this.blueprintsRoot, 'draft', slug).flat;
171
172
  const relativeFilePath = toPortableRelativePath(this.projectRoot, outputPath);
172
173
  const date = formatDate(this.now());
173
174
  const template = type === 'blueprint' ? prepareTemplate(await readFile(this.templatePath, 'utf-8')) : undefined;
@@ -205,11 +206,11 @@ export class BlueprintCreationService {
205
206
  async create(input) {
206
207
  const draft = await this.compileDraft(input);
207
208
  const draftRoot = path.join(this.blueprintsRoot, 'draft');
208
- const finalDir = path.dirname(draft.outputPath);
209
+ const finalPath = draft.outputPath;
209
210
  await mkdir(draftRoot, { recursive: true });
210
- await mkdir(path.dirname(finalDir), { recursive: true });
211
+ await mkdir(path.dirname(finalPath), { recursive: true });
211
212
  const tempDir = await mkdtemp(path.join(draftRoot, `${draft.slug}.tmp-`));
212
- const tempPath = path.join(tempDir, '_overview.md');
213
+ const tempPath = path.join(tempDir, `${draft.slug}.md`);
213
214
  try {
214
215
  await writeFile(tempPath, draft.markdown, 'utf-8');
215
216
  const writtenMarkdown = await readFile(tempPath, 'utf-8');
@@ -217,7 +218,8 @@ export class BlueprintCreationService {
217
218
  if (!validation.valid || !validation.blueprint) {
218
219
  throw new Error(validation.error ?? 'Generated blueprint failed validation.');
219
220
  }
220
- await rename(tempDir, finalDir);
221
+ await rename(tempPath, finalPath);
222
+ await rm(tempDir, { force: true, recursive: true });
221
223
  return {
222
224
  ...draft,
223
225
  blueprint: validation.blueprint,
@@ -247,7 +249,10 @@ export class BlueprintCreationService {
247
249
  }
248
250
  }
249
251
  function blueprintDirectoryExists(blueprintsRoot, slug) {
250
- return [...RESERVED_BLUEPRINT_SLUGS].some((status) => existsSync(path.join(blueprintsRoot, status, slug)));
252
+ return [...RESERVED_BLUEPRINT_SLUGS].some((status) => {
253
+ const paths = getBlueprintDocumentPaths(blueprintsRoot, status, slug);
254
+ return existsSync(paths.directory) || existsSync(paths.flat);
255
+ });
251
256
  }
252
257
  async function removeIfEmpty(directory) {
253
258
  try {
@@ -11,6 +11,7 @@ import { parseBlueprint } from '#core/parser';
11
11
  import { applyBlueprintLifecycleToFile } from '#lifecycle/local';
12
12
  import { resolveBlueprintRoot } from '#utils/blueprint-root';
13
13
  import { emitTraceArtifact, generateBlueprintLifecycleTrace } from '#utils/decision-trace-artifacts';
14
+ import { getBlueprintDocumentCandidates } from '#utils/document-paths.js';
14
15
  import { BlueprintNotFoundError } from '#utils/errors';
15
16
  import { computeBlueprintQuerySummary, matchesBlueprintFilters, sortBlueprintRecords, toBlueprintRecord, } from './blueprint-records.js';
16
17
  import { linkBlueprintToTechDebt, unlinkBlueprintFromTechDebt, } from './blueprint-tech-debt-links.js';
@@ -74,25 +75,28 @@ export class BlueprintService extends TrackedDocumentService {
74
75
  }
75
76
  async get(slug) {
76
77
  // Try direct path first (supports both 'in-progress/foo' and 'foo')
77
- const planPath = path.join(this.baseDir, slug, '_overview.md');
78
- try {
79
- await fs.access(planPath);
80
- const content = await fs.readFile(planPath, 'utf-8');
81
- return parseBlueprint(content, slug);
82
- }
83
- catch {
84
- // Scan all plans to find a match by slug
85
- const scannedPlans = scanBlueprintDirectory({
86
- baseDir: this.baseDir,
87
- includeSpecialFolders: true,
88
- });
89
- const found = scannedPlans.find((p) => p.slug === slug || p.slug.endsWith(`/${slug}`));
90
- if (!found) {
91
- throw new BlueprintNotFoundError(slug, planPath, scannedPlans.map((p) => p.slug));
78
+ const directCandidates = getBlueprintDocumentCandidates(this.baseDir, slug);
79
+ for (const candidate of directCandidates) {
80
+ try {
81
+ await fs.access(candidate);
82
+ const content = await fs.readFile(candidate, 'utf-8');
83
+ return parseBlueprint(content, slug);
84
+ }
85
+ catch {
86
+ // Keep trying the remaining canonical shapes before falling back to a scan.
92
87
  }
93
- const content = await fs.readFile(found.path, 'utf-8');
94
- return parseBlueprint(content, found.slug);
95
88
  }
89
+ const searchedPath = directCandidates[0] ?? path.join(this.baseDir, slug, '_overview.md');
90
+ const scannedPlans = scanBlueprintDirectory({
91
+ baseDir: this.baseDir,
92
+ includeSpecialFolders: true,
93
+ });
94
+ const found = scannedPlans.find((p) => p.slug === slug || p.slug.endsWith(`/${slug}`));
95
+ if (!found) {
96
+ throw new BlueprintNotFoundError(slug, searchedPath, scannedPlans.map((p) => p.slug));
97
+ }
98
+ const content = await fs.readFile(found.path, 'utf-8');
99
+ return parseBlueprint(content, found.slug);
96
100
  }
97
101
  async query(options) {
98
102
  const scannedPlans = scanBlueprintDirectory({ baseDir: this.baseDir });
@@ -153,8 +157,22 @@ export class BlueprintService extends TrackedDocumentService {
153
157
  * @returns Array of TechDebtRecord objects
154
158
  */
155
159
  async getLinkedTechDebt(bpSlug) {
156
- const blueprintPath = path.join(this.baseDir, bpSlug, '_overview.md');
157
- const content = await fs.readFile(blueprintPath, 'utf-8');
160
+ const directCandidates = getBlueprintDocumentCandidates(this.baseDir, bpSlug);
161
+ let resolvedBlueprintPath = null;
162
+ for (const candidate of directCandidates) {
163
+ try {
164
+ await fs.access(candidate);
165
+ resolvedBlueprintPath = candidate;
166
+ break;
167
+ }
168
+ catch {
169
+ // Keep trying the other canonical shape.
170
+ }
171
+ }
172
+ const filePath = resolvedBlueprintPath ??
173
+ directCandidates[0] ??
174
+ path.join(this.baseDir, bpSlug, '_overview.md');
175
+ const content = await fs.readFile(filePath, 'utf-8');
158
176
  const parsed = matter(content);
159
177
  const data = JSON.parse(JSON.stringify(parsed.data));
160
178
  const linkedTechDebtSlugs = data.linked_tech_debt_slugs ?? [];
@@ -7,10 +7,18 @@
7
7
  import { existsSync, readdirSync, statSync } from 'node:fs';
8
8
  import { isAbsolute, join, relative, resolve } from 'node:path';
9
9
  import { resolveBlueprintRoot } from '#utils/blueprint-root';
10
+ import { BLUEPRINT_OVERVIEW_FILENAME, parseBlueprintDocumentRelativePath, } from '#utils/document-paths.js';
11
+ const BLUEPRINT_STATUS_FOLDERS = new Set([
12
+ 'draft',
13
+ 'planned',
14
+ 'parked',
15
+ 'in-progress',
16
+ 'completed',
17
+ 'archived',
18
+ ]);
10
19
  /** Special folder prefixes that indicate archived/deferred plans */
11
20
  const SPECIAL_FOLDERS = ['_completed', '_future', '_deprioritized'];
12
21
  /** Standard plan overview filename */
13
- const OVERVIEW_FILENAME = '_overview.md';
14
22
  /**
15
23
  * Check if a path component is a special folder.
16
24
  */
@@ -28,6 +36,9 @@ function findSpecialFolderType(pathSegments) {
28
36
  }
29
37
  return undefined;
30
38
  }
39
+ function isStatusFolder(name) {
40
+ return BLUEPRINT_STATUS_FOLDERS.has(name);
41
+ }
31
42
  /**
32
43
  * Extract the slug and group from a plan path.
33
44
  *
@@ -39,15 +50,42 @@ function findSpecialFolderType(pathSegments) {
39
50
  * - 'webpresso/blueprints/_completed/old-plan/_overview.md'
40
51
  * -> slug: '_completed/old-plan', group: null (special folder)
41
52
  */
42
- function extractSlugAndGroup(fullPath, baseDir, filePattern = OVERVIEW_FILENAME) {
43
- // Get relative path from base directory
53
+ function extractSlugAndGroup(fullPath, baseDir, filePattern = BLUEPRINT_OVERVIEW_FILENAME) {
44
54
  const relPath = relative(baseDir, fullPath);
45
- // Split into segments and remove the document filename
46
- const segments = relPath.split('/').filter((s) => s !== filePattern && s !== '');
55
+ const canonicalDocument = parseBlueprintDocumentRelativePath(relPath);
56
+ if (canonicalDocument) {
57
+ const slug = `${canonicalDocument.state}/${canonicalDocument.slug}`;
58
+ return {
59
+ slug,
60
+ group: canonicalDocument.state,
61
+ };
62
+ }
63
+ const relSegments = relPath.split('/').filter((s) => s !== '');
64
+ const segments = [...relSegments];
65
+ if (!segments.length) {
66
+ return { slug: '', group: null };
67
+ }
68
+ if (filePattern === BLUEPRINT_OVERVIEW_FILENAME) {
69
+ if (segments[segments.length - 1] === filePattern) {
70
+ segments.pop();
71
+ }
72
+ }
73
+ else if (filePattern.toLowerCase() === 'readme.md') {
74
+ if (segments[segments.length - 1] === filePattern) {
75
+ segments.pop();
76
+ }
77
+ }
78
+ else {
79
+ const last = segments[segments.length - 1] ?? '';
80
+ if (last === filePattern) {
81
+ segments[segments.length - 1] = last.replace(/\.md$/i, '');
82
+ }
83
+ }
47
84
  if (!segments.length) {
48
85
  return { slug: '', group: null };
49
86
  }
50
- // Filter out special folders from the slug calculation for group determination
87
+ // Filter out archival special folders from group determination. Lifecycle
88
+ // status folders are part of the public slug and remain valid groups.
51
89
  const nonSpecialSegments = segments.filter((s) => !isSpecialFolder(s));
52
90
  // The slug is the full path (including special folders)
53
91
  const slug = segments.join('/');
@@ -68,7 +106,7 @@ function extractSlugAndGroup(fullPath, baseDir, filePattern = OVERVIEW_FILENAME)
68
106
  * Check if an entry should be skipped during directory traversal.
69
107
  */
70
108
  function shouldSkipEntry(entry) {
71
- return entry.startsWith('.') || entry === 'node_modules';
109
+ return entry.startsWith('.') || entry.startsWith('__') || entry === 'node_modules';
72
110
  }
73
111
  /**
74
112
  * Safely get file stats, returning null on error.
@@ -126,11 +164,22 @@ function processEntry(entry, dir, baseDir, filePattern, includeSpecialFolders, r
126
164
  }
127
165
  return;
128
166
  }
167
+ if (filePattern === BLUEPRINT_OVERVIEW_FILENAME &&
168
+ entry.endsWith('.md') &&
169
+ entry !== 'README.md') {
170
+ const relativeParentSegments = relative(baseDir, dir).split('/').filter((s) => s !== '');
171
+ if (relativeParentSegments.length === 1 && isStatusFolder(relativeParentSegments[0] ?? '')) {
172
+ const plan = processPlanFile(fullPath, baseDir, includeSpecialFolders, entry);
173
+ if (plan) {
174
+ results.push(plan);
175
+ }
176
+ }
177
+ }
129
178
  }
130
179
  /**
131
180
  * Process a plan file (_overview.md or _overview.md) and create a ScannedBlueprint if applicable.
132
181
  */
133
- function processPlanFile(fullPath, baseDir, includeSpecialFolders, filePattern = OVERVIEW_FILENAME) {
182
+ function processPlanFile(fullPath, baseDir, includeSpecialFolders, filePattern = BLUEPRINT_OVERVIEW_FILENAME) {
134
183
  const relativePath = relative(baseDir, fullPath);
135
184
  // Skip files in hidden directories (defense-in-depth check)
136
185
  if (containsHiddenDirectory(relativePath)) {
@@ -192,6 +241,21 @@ export function scanDocumentDirectory(options) {
192
241
  }
193
242
  const results = [];
194
243
  scanDirectory(absoluteBaseDir, absoluteBaseDir, filePattern, includeSpecialFolders, results);
244
+ const duplicates = new Map();
245
+ for (const result of results) {
246
+ const existing = duplicates.get(result.slug);
247
+ if (existing) {
248
+ existing.push(result.path);
249
+ }
250
+ else {
251
+ duplicates.set(result.slug, [result.path]);
252
+ }
253
+ }
254
+ const duplicate = Array.from(duplicates.entries()).find(([, paths]) => paths.length > 1);
255
+ if (duplicate) {
256
+ const [slug, paths] = duplicate;
257
+ throw new Error(`Duplicate blueprint slug "${slug}" found in multiple canonical shapes: ${paths.join(', ')}`);
258
+ }
195
259
  return results;
196
260
  }
197
261
  /**
@@ -205,7 +269,7 @@ export function scanBlueprintDirectory(options) {
205
269
  const includeSpecialFolders = options?.includeSpecialFolders ?? false;
206
270
  return scanDocumentDirectory({
207
271
  baseDir,
208
- filePattern: OVERVIEW_FILENAME,
272
+ filePattern: BLUEPRINT_OVERVIEW_FILENAME,
209
273
  includeSpecialFolders,
210
274
  });
211
275
  }
@@ -18,8 +18,8 @@ export declare const trackedDocumentStatusSchema: z.ZodEnum<{
18
18
  completed: "completed";
19
19
  draft: "draft";
20
20
  planned: "planned";
21
- "in-progress": "in-progress";
22
21
  parked: "parked";
22
+ "in-progress": "in-progress";
23
23
  archived: "archived";
24
24
  }>;
25
25
  export type TrackedDocumentStatus = z.infer<typeof trackedDocumentStatusSchema>;
@@ -54,8 +54,8 @@ export declare const trackedDocumentFrontmatterSchema: z.ZodObject<{
54
54
  completed: "completed";
55
55
  draft: "draft";
56
56
  planned: "planned";
57
- "in-progress": "in-progress";
58
57
  parked: "parked";
58
+ "in-progress": "in-progress";
59
59
  archived: "archived";
60
60
  }>;
61
61
  last_updated: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodDate]>>;
@@ -0,0 +1,23 @@
1
+ export declare const BLUEPRINT_OVERVIEW_FILENAME = "_overview.md";
2
+ export declare const BLUEPRINT_STATUSES: readonly ["draft", "planned", "parked", "in-progress", "completed", "archived"];
3
+ export type BlueprintStatus = (typeof BLUEPRINT_STATUSES)[number];
4
+ export type BlueprintShape = 'flat' | 'folder';
5
+ export interface BlueprintDocumentPath {
6
+ relativePath: string;
7
+ shape: BlueprintShape;
8
+ slug: string;
9
+ state: BlueprintStatus;
10
+ }
11
+ export declare function normalizeBlueprintPath(filePath: string): string;
12
+ export declare function isBlueprintStatus(value: string | undefined): value is BlueprintStatus;
13
+ export declare function isBlueprintSlugSegment(value: string | undefined): value is string;
14
+ export declare function parseBlueprintDocumentRelativePath(filePath: string): BlueprintDocumentPath | null;
15
+ export declare function isBlueprintSupportingMarkdownRelativePath(filePath: string): boolean;
16
+ export declare function getBlueprintDocumentPaths(root: string, state: BlueprintStatus, slug: string): {
17
+ directory: string;
18
+ flat: string;
19
+ folder: string;
20
+ };
21
+ export declare function getBlueprintDocumentCandidates(root: string, slug: string): string[];
22
+ export declare function getBlueprintAlternateDocumentPath(root: string, filePath: string): string | null;
23
+ //# sourceMappingURL=document-paths.d.ts.map