@namewta/speculo 1.0.9 → 1.0.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namewta/speculo",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "Workflow-packaged AI collaboration assets with state-safe refresh tooling.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@
14
14
 
15
15
  ## 读取顺序
16
16
 
17
- 1. 读取 `workspace.json`,以当前打开项目为 `project_root` 解析公共 roots
17
+ 1. 读取 `workspace.json`,以当前打开项目为 `project_root` 解析公共 roots。嵌套安装时,项目根 `.speculo/` 不是本目录;只有该文件声明的状态根是运行时状态的唯一持久化根,项目根 `.speculo/specdev` 非法。
18
18
  2. 从 `../workflows/<workflow>/INDEX.md` 发现 workflow并按需读取其中声明的永久知识;这一步不读取 Work 条目或运行状态。
19
19
  3. 用户明确激活 workflow 或 work 后,读取 INDEX 指向的 workflow 根 `README.md`,从其中的 Work 条目选择目标并读取具体入口文件。
20
20
  4. 按激活合同读取 `<Path>{roots.state}/{workflow}/status.json</Path>`。SpecDev/Learning 再读取当前 change `.status.json` 与 work 产物。Ops schema v3 读取 hosts/projects/deployments/allocations/bindings/releases 及对应运行记录,不创建 `changes/`。
@@ -1921,7 +1921,7 @@ node "${ZIP_SCRIPT}" "${RETURN_STAGING}" \
1921
1921
 
1922
1922
  ## Locate before read
1923
1923
 
1924
- 1. 先解析当前 workflow 的 roots、状态索引和稳定 ID;不存在时静默跳过,不能凭旧路径猜测。
1924
+ 1. 先打开 `workspace.json`,从中解析当前 workflow 的 roots、状态索引和稳定 ID;roots 必须来自该文件,不得凭字面猜测。不存在时静默跳过,不能凭旧路径猜测。
1925
1925
  2. 根据当前请求、Work 分支、关键词、稳定 ID、状态和 provenance,先搜索相关索引行或目录项,再定位最小相关 entry;不把索引全文默认装入上下文。
1926
1926
  3. 只回读命中的 entry 和直接 provenance;需要恢复、冲突裁决、归档、迁移或执行安全证明时,才读取该阶段声明的完整证据集合。
1927
1927
  4. 没有匹配证据时返回缺失证据并停止依赖该结论的分支,不补造事实。
@@ -982,7 +982,7 @@ Ticket 只有同时满足以下适用条件才可设置 `ready: true`:
982
982
 
983
983
  ## Locate before read
984
984
 
985
- 1. 先解析当前 workflow 的 roots、状态索引和稳定 ID;不存在时静默跳过,不能凭旧路径猜测。
985
+ 1. 先打开 `workspace.json`,从中解析当前 workflow 的 roots、状态索引和稳定 ID;roots 必须来自该文件,不得凭字面猜测。不存在时静默跳过,不能凭旧路径猜测。
986
986
  2. 根据当前请求、Work 分支、关键词、稳定 ID、状态和 provenance,先搜索相关索引行或目录项,再定位最小相关 entry;不把索引全文默认装入上下文。
987
987
  3. 只回读命中的 entry 和直接 provenance;需要恢复、冲突裁决、归档、迁移或执行安全证明时,才读取该阶段声明的完整证据集合。
988
988
  4. 没有匹配证据时返回缺失证据并停止依赖该结论的分支,不补造事实。
@@ -1072,7 +1072,7 @@ Direct Spec Evidence 至少包含:用户批准与轻量合同、Lead、实施
1072
1072
 
1073
1073
  ## Locate before read
1074
1074
 
1075
- 1. 先解析当前 workflow 的 roots、状态索引和稳定 ID;不存在时静默跳过,不能凭旧路径猜测。
1075
+ 1. 先打开 `workspace.json`,从中解析当前 workflow 的 roots、状态索引和稳定 ID;roots 必须来自该文件,不得凭字面猜测。不存在时静默跳过,不能凭旧路径猜测。
1076
1076
  2. 根据当前请求、Work 分支、关键词、稳定 ID、状态和 provenance,先搜索相关索引行或目录项,再定位最小相关 entry;不把索引全文默认装入上下文。
1077
1077
  3. 只回读命中的 entry 和直接 provenance;需要恢复、冲突裁决、归档、迁移或执行安全证明时,才读取该阶段声明的完整证据集合。
1078
1078
  4. 没有匹配证据时返回缺失证据并停止依赖该结论的分支,不补造事实。
@@ -1867,7 +1867,7 @@ implementation owner 只在来源 worktree 修改授权项目路径,运行 Tic
1867
1867
 
1868
1868
  ## Locate before read
1869
1869
 
1870
- 1. 先解析当前 workflow 的 roots、状态索引和稳定 ID;不存在时静默跳过,不能凭旧路径猜测。
1870
+ 1. 先打开 `workspace.json`,从中解析当前 workflow 的 roots、状态索引和稳定 ID;roots 必须来自该文件,不得凭字面猜测。不存在时静默跳过,不能凭旧路径猜测。
1871
1871
  2. 根据当前请求、Work 分支、关键词、稳定 ID、状态和 provenance,先搜索相关索引行或目录项,再定位最小相关 entry;不把索引全文默认装入上下文。
1872
1872
  3. 只回读命中的 entry 和直接 provenance;需要恢复、冲突裁决、归档、迁移或执行安全证明时,才读取该阶段声明的完整证据集合。
1873
1873
  4. 没有匹配证据时返回缺失证据并停止依赖该结论的分支,不补造事实。
@@ -650,7 +650,7 @@ resolution: answered
650
650
 
651
651
  ## Locate before read
652
652
 
653
- 1. 先解析当前 workflow 的 roots、状态索引和稳定 ID;不存在时静默跳过,不能凭旧路径猜测。
653
+ 1. 先打开 `workspace.json`,从中解析当前 workflow 的 roots、状态索引和稳定 ID;roots 必须来自该文件,不得凭字面猜测。不存在时静默跳过,不能凭旧路径猜测。
654
654
  2. 根据当前请求、Work 分支、关键词、稳定 ID、状态和 provenance,先搜索相关索引行或目录项,再定位最小相关 entry;不把索引全文默认装入上下文。
655
655
  3. 只回读命中的 entry 和直接 provenance;需要恢复、冲突裁决、归档、迁移或执行安全证明时,才读取该阶段声明的完整证据集合。
656
656
  4. 没有匹配证据时返回缺失证据并停止依赖该结论的分支,不补造事实。
@@ -35,12 +35,16 @@ keywords: [初始化, 配置, status, tracking, 验证命令]
35
35
 
36
36
  ### 1. 解析根目录
37
37
 
38
+ 创建任何状态目录之前,先从 cwd 向上定位并打开 `<Path>{roots.state}/workspace.json</Path>`,校验 `path_base` 与 `roots.*`。只在解析后的状态根下创建 specdev 目录;禁止在声明的状态根之外新建 `.speculo`。
39
+
38
40
  确认:
39
41
 
40
42
  - 工作流根可解析为 `<Path>{roots.workflows}/specdev/</Path>`;
41
- - 状态根可解析为 `<Path>{roots.state}/specdev/</Path>`;
43
+ - 状态根可解析为 `<Path>{roots.state}/specdev/</Path>`,且必须来自已打开的 `<Path>{roots.state}/workspace.json</Path>`;
42
44
  - 当前用户允许在状态根创建目录和工件。
43
45
 
46
+ 嵌套安装反例:不得把状态根默认展开成项目根 `.speculo`。项目根 `.speculo/specdev` 非法;init 与 Grill 只写入声明的 `<Path>{roots.state}/specdev/</Path>`。
47
+
44
48
  不得把真实绝对路径写回模板或治理文档;持久化引用继续使用根变量。
45
49
 
46
50
  ### 2. 探测项目事实
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 运行时根
6
6
 
7
- 工作流:`<Path>{roots.workflows}/specdev/</Path>`;状态:`<Path>{roots.state}/specdev/</Path>`。具体路径遵守 `<Path>{roots.workflows}/specdev/common/rules/path-reference-contract.md</Path>`,不使用内部相对链接、裸文件名或机器绝对路径。
7
+ 工作流:`<Path>{roots.workflows}/specdev/</Path>`;状态:`<Path>{roots.state}/specdev/</Path>`。roots 必须来自已打开的 `<Path>{roots.state}/workspace.json</Path>`,禁止把状态根默认展开成项目根 `.speculo`。嵌套安装下项目根 `.speculo/specdev` 非法。具体路径遵守 `<Path>{roots.workflows}/specdev/common/rules/path-reference-contract.md</Path>`,不使用内部相对链接、裸文件名或机器绝对路径。
8
8
 
9
9
  ## 工件链与权威
10
10
 
@@ -24,7 +24,7 @@ CLI 初始化和刷新保持原 namespace、三方配置合并、schema migrator
24
24
 
25
25
  ## 启动协议
26
26
 
27
- 1. 解析 roots,读取 `<Path>{roots.workflows}/specdev/common/rules/activation-and-memory.md</Path>`;定位相关 entry 后回读必要原文,不默认整读索引。
27
+ 1. 先打开 `<Path>{roots.state}/workspace.json</Path>` 解析 roots,再读取 `<Path>{roots.workflows}/specdev/common/rules/activation-and-memory.md</Path>`;定位相关 entry 后回读必要原文,不默认整读索引。不得把状态根默认展开成项目根 `.speculo`;项目根 `.speculo/specdev` 非法。
28
28
  2. 读取 `<Path>{roots.state}/specdev/config.json</Path>`;不存在时使用 `<Path>{roots.workflows}/specdev/I-init-setup/I-init-setup.md</Path>`。保留已知配置,不重复询问。
29
29
  3. 从 `<Path>{roots.state}/specdev/status.json</Path>` 定位用户指定或唯一 active change;多个候选需要真实消歧,无候选按原规则创建。
30
30
  4. 读取 `<Path>{roots.state}/specdev/changes/{change}/.status.json</Path>`;恢复、创建或状态修改时加载上述状态细则。child 归属未完成父 Goal 时读取对应父 Map/Plan,不接管或覆盖其 owner。
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Locate before read
6
6
 
7
- 1. 先解析当前 workflow 的 roots、状态索引和稳定 ID;不存在时静默跳过,不能凭旧路径猜测。
7
+ 1. 先打开 `<Path>{roots.state}/workspace.json</Path>`,从中解析当前 workflow 的 roots、状态索引和稳定 ID;roots 必须来自该文件,不得凭字面猜测。不存在时静默跳过,不能凭旧路径猜测。
8
8
  2. 根据当前请求、Work 分支、关键词、稳定 ID、状态和 provenance,先搜索相关索引行或目录项,再定位最小相关 entry;不把索引全文默认装入上下文。
9
9
  3. 只回读命中的 entry 和直接 provenance;需要恢复、冲突裁决、归档、迁移或执行安全证明时,才读取该阶段声明的完整证据集合。
10
10
  4. 没有匹配证据时返回缺失证据并停止依赖该结论的分支,不补造事实。
@@ -44,6 +44,8 @@
44
44
 
45
45
  目录引用必须以 `/` 结束;文件引用不得以 `/` 结束。
46
46
 
47
+ 嵌套安装反例:项目根 `.speculo/specdev` 不是状态根。change 必须位于 `<Path>{roots.state}/specdev/changes/{change}/</Path>` 或 `<Path>{roots.state}/specdev/archive/YYYY-MM/{change}/</Path>`。
48
+
47
49
  ## 3. 项目代码路径
48
50
 
49
51
  SpecDev 不假定运行时一定提供项目根变量。Ticket、Evidence、诊断与架构审查中的项目代码路径统一使用项目相对路径,并仍置于 Path 标签中:
@@ -17,7 +17,7 @@ import {
17
17
  statSync,
18
18
  } from "node:fs";
19
19
  import { execFileSync } from "node:child_process";
20
- import { dirname, basename, extname, join, relative, resolve, sep } from "node:path";
20
+ import { dirname, basename, extname, join, relative, resolve, sep, isAbsolute } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
22
 
23
23
  import {
@@ -262,7 +262,7 @@ function parseScalar(raw) {
262
262
  return value;
263
263
  }
264
264
 
265
- function findSpecdevConfig(change) {
265
+ function findSpecdevConfigByAncestry(change) {
266
266
  let current = resolve(change);
267
267
  while (true) {
268
268
  const candidate = join(current, ".speculo", "specdev", "config.json");
@@ -279,6 +279,149 @@ function findSpecdevConfig(change) {
279
279
  }
280
280
  }
281
281
 
282
+ function uniqueResolved(paths) {
283
+ const seen = new Set();
284
+ const result = [];
285
+ for (const path of paths) {
286
+ const resolved = resolve(path);
287
+ if (seen.has(resolved)) continue;
288
+ seen.add(resolved);
289
+ result.push(resolved);
290
+ }
291
+ return result;
292
+ }
293
+
294
+ function ancestorsOf(start) {
295
+ const dirs = [];
296
+ let current = resolve(start);
297
+ while (true) {
298
+ dirs.push(current);
299
+ const parent = dirname(current);
300
+ if (parent === current) break;
301
+ current = parent;
302
+ }
303
+ return dirs;
304
+ }
305
+
306
+ function parseWorkspaceCandidate(filePath, projectRoot) {
307
+ if (!isFile(filePath)) return null;
308
+ let data;
309
+ try {
310
+ data = JSON.parse(readText(filePath));
311
+ } catch {
312
+ return null;
313
+ }
314
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
315
+ if (data.schema_version !== 1 || data.path_base !== "project-root") return null;
316
+ const state = data.roots?.state;
317
+ if (typeof state !== "string" || !state.trim()) return null;
318
+ const declared = toPosix(state).replace(/^\.?\//, "").replace(/\/$/, "");
319
+ if (
320
+ !declared ||
321
+ declared.split("/").includes("..") ||
322
+ isAbsolute(declared) ||
323
+ ABSOLUTE_MACHINE_PATH_RE.test(declared)
324
+ ) {
325
+ return null;
326
+ }
327
+ const expected = resolve(projectRoot, declared, "workspace.json");
328
+ if (expected !== resolve(filePath)) return null;
329
+ return {
330
+ filePath: resolve(filePath),
331
+ projectRoot: resolve(projectRoot),
332
+ stateDeclared: declared,
333
+ stateRoot: resolve(projectRoot, declared),
334
+ data,
335
+ };
336
+ }
337
+
338
+ function workspaceProbes(projectRoot) {
339
+ const root = resolve(projectRoot);
340
+ return [
341
+ { projectRoot: root, filePath: join(root, "speculo", ".speculo", "workspace.json") },
342
+ { projectRoot: root, filePath: join(root, ".speculo", "workspace.json") },
343
+ ];
344
+ }
345
+
346
+ function collectWorkspaceCandidates(repoRoot, change) {
347
+ const probes = [];
348
+ if (repoRoot) {
349
+ probes.push(...workspaceProbes(repoRoot));
350
+ } else {
351
+ for (const dir of uniqueResolved([...ancestorsOf(change), ...ancestorsOf(process.cwd())])) {
352
+ probes.push(...workspaceProbes(dir));
353
+ }
354
+ }
355
+ const found = new Map();
356
+ for (const probe of probes) {
357
+ const parsed = parseWorkspaceCandidate(probe.filePath, probe.projectRoot);
358
+ if (!parsed) continue;
359
+ if (!found.has(parsed.filePath)) found.set(parsed.filePath, parsed);
360
+ }
361
+ return [...found.values()];
362
+ }
363
+
364
+ function isLegalChangeLocation(changeAbs, stateRoot) {
365
+ const name = basename(changeAbs);
366
+ if (resolve(stateRoot, "specdev", "changes", name) === changeAbs) return true;
367
+ const monthDir = dirname(changeAbs);
368
+ const archiveRoot = dirname(monthDir);
369
+ return (
370
+ /^\d{4}-\d{2}$/.test(basename(monthDir)) &&
371
+ resolve(stateRoot, "specdev", "archive") === archiveRoot &&
372
+ basename(changeAbs) === name
373
+ );
374
+ }
375
+
376
+ function resolveWorkspaceContract(repoRoot, change) {
377
+ const changeAbs = resolve(change);
378
+ const candidates = collectWorkspaceCandidates(repoRoot, changeAbs);
379
+ if (!candidates.length) {
380
+ return { mode: "legacy", errors: [], config: null, specdevRoot: null, stateDeclared: null };
381
+ }
382
+ const uniqueRoots = uniqueResolved(candidates.map((candidate) => candidate.stateRoot));
383
+ if (uniqueRoots.length > 1) {
384
+ const declared = [...new Set(candidates.map((candidate) => candidate.stateDeclared))].sort();
385
+ return {
386
+ mode: "strict",
387
+ errors: [`conflicting workspace.json roots.state (${declared.join(" vs ")})`],
388
+ config: null,
389
+ specdevRoot: null,
390
+ stateDeclared: null,
391
+ };
392
+ }
393
+ const workspace = candidates[0];
394
+ const specdevRoot = join(workspace.stateRoot, "specdev");
395
+ const errors = [];
396
+ if (!isLegalChangeLocation(changeAbs, workspace.stateRoot)) {
397
+ errors.push(
398
+ `change is outside workspace roots.state (${workspace.stateDeclared}/specdev); project-root .speculo/specdev is illegal`,
399
+ );
400
+ }
401
+ let config = null;
402
+ const configPath = join(specdevRoot, "config.json");
403
+ if (isFile(configPath)) {
404
+ try {
405
+ config = JSON.parse(readText(configPath));
406
+ } catch {
407
+ config = null;
408
+ }
409
+ }
410
+ return {
411
+ mode: "strict",
412
+ errors,
413
+ config,
414
+ specdevRoot,
415
+ stateDeclared: workspace.stateDeclared,
416
+ };
417
+ }
418
+
419
+ function findSpecdevConfig(change, repoRoot = null) {
420
+ const contract = resolveWorkspaceContract(repoRoot, change);
421
+ if (contract.mode === "strict") return contract.config;
422
+ return findSpecdevConfigByAncestry(change);
423
+ }
424
+
282
425
  function positiveConfigLimit(config, key, fallback) {
283
426
  const value = config?.execution?.[key];
284
427
  return Number.isInteger(value) && value >= 1 ? value : fallback;
@@ -1701,9 +1844,9 @@ function validateGoalPlan(path, errors) {
1701
1844
  return { path, meta, body };
1702
1845
  }
1703
1846
 
1704
- function validateGoalPlanRuntimeLimits(path, change, goalPlan, errors) {
1847
+ function validateGoalPlanRuntimeLimits(path, change, goalPlan, errors, repoRoot = null) {
1705
1848
  if (!goalPlan) return;
1706
- const config = findSpecdevConfig(change);
1849
+ const config = findSpecdevConfig(change, repoRoot);
1707
1850
  if (!config) {
1708
1851
  errors.push(`${basename(path)}: SpecDev config.json is required to validate execution limits`);
1709
1852
  return;
@@ -2758,7 +2901,7 @@ function validateParentImplementation(change, parentStatus, stage, errors, warni
2758
2901
  if (overlap.length) errors.push(`member changes already belong to unfinished parent implementation ${entry.name}: ${JSON.stringify(overlap)}`);
2759
2902
  }
2760
2903
 
2761
- const config = findSpecdevConfig(change);
2904
+ const config = findSpecdevConfig(change, repoRoot);
2762
2905
  const configuredAgents = positiveConfigLimit(config, "max_implementation_agents", 0);
2763
2906
  const configuredAttempts = positiveConfigLimit(config, "max_integration_attempts", 0);
2764
2907
  if (!config || config.schema_version !== CONFIG_SCHEMA_VERSION || configuredAgents === 0 || configuredAttempts === 0) {
@@ -2849,6 +2992,9 @@ function validateChange(change, stage = null, repoRoot = null) {
2849
2992
  return { errors: [`change directory does not exist: ${change}`], warnings };
2850
2993
  }
2851
2994
 
2995
+ const workspace = resolveWorkspaceContract(repoRoot, change);
2996
+ errors.push(...workspace.errors);
2997
+
2852
2998
  const changeStatus = validateChangeStatus(join(change, ".status.json"), basename(change), errors);
2853
2999
  validateParentImplementation(change, changeStatus, stage, errors, warnings, repoRoot);
2854
3000
  errors.push(...validateInitiative(change));
@@ -2972,7 +3118,7 @@ function validateChange(change, stage = null, repoRoot = null) {
2972
3118
  }
2973
3119
  }
2974
3120
  if (changeStatus && goalPlan) {
2975
- validateGoalPlanRuntimeLimits(goalPlan.path, change, goalPlan, errors);
3121
+ validateGoalPlanRuntimeLimits(goalPlan.path, change, goalPlan, errors, repoRoot);
2976
3122
  const attemptLimit = Number.isInteger(goalPlan.meta.integration_attempt_limit) ? goalPlan.meta.integration_attempt_limit : null;
2977
3123
  if (attemptLimit !== null) {
2978
3124
  for (const worktree of changeStatus.worktrees ?? []) {