@mstar-harness/engine 2.1.0 → 2.2.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/README.md CHANGED
@@ -19,7 +19,7 @@ const version = readHarnessVersion(); // "1.8.8" — monorepo root package.json
19
19
  ## Scope
20
20
 
21
21
  - Importable library only — **no `bin`**; the CLI (`@mstar-harness/cli`) wraps engine functions as thin `mstar …` subcommands.
22
- - Dependencies: `node:*` only (zero external runtime deps — all validators hand-rolled; ajv pruned as phantom, zod removed 2026-08-08).
22
+ - Dependencies: `node:*` only (zero external runtime deps — all validators hand-rolled; ajv and zod were pruned as phantom dependencies).
23
23
  - Skill prose stays authoritative; engine exports are the machine-checkable mirror of the rules the `mstar-*` skills state.
24
24
 
25
25
  ## License
package/dist/engine.js CHANGED
@@ -83,24 +83,52 @@ function readHarnessVersion() {
83
83
  }
84
84
  // src/path.ts
85
85
  import { mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
86
+ import { execFileSync } from "node:child_process";
86
87
  import { basename as basename2, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "node:path";
87
88
  function resolveHarnessDir(startDir = process.cwd(), opts = {}) {
88
89
  const start = resolve2(startDir);
89
90
  const explicit = opts.harnessDir ?? process.env.MSTAR_HARNESS_DIR;
90
91
  if (explicit)
91
92
  return resolve2(start, explicit);
93
+ const boundary = resolve2(start, opts.workspaceRoot ?? defaultWorkspaceRoot(start));
92
94
  let dir = start;
93
95
  for (;; ) {
96
+ if (!isAtOrBelow(dir, boundary))
97
+ return null;
94
98
  for (const candidate of [join2(dir, ".mstar"), join2(dir, ".agents"), join2(dir, ".plans"), join2(dir, "plans")]) {
95
99
  if (isDirectory(candidate))
96
100
  return candidate;
97
101
  }
102
+ if (dir === boundary)
103
+ return null;
98
104
  const parent = dirname2(dir);
99
105
  if (parent === dir)
100
106
  return null;
101
107
  dir = parent;
102
108
  }
103
109
  }
110
+ function defaultWorkspaceRoot(startDir) {
111
+ try {
112
+ const cdup = execFileSync("git", ["rev-parse", "--show-cdup"], {
113
+ cwd: startDir,
114
+ encoding: "utf8",
115
+ stdio: ["ignore", "pipe", "ignore"]
116
+ }).trim();
117
+ if (!cdup)
118
+ return startDir;
119
+ let boundary = startDir;
120
+ for (const segment of cdup.split(/[\\/]/)) {
121
+ if (segment && segment !== ".")
122
+ boundary = dirname2(boundary);
123
+ }
124
+ return resolve2(boundary);
125
+ } catch {}
126
+ return startDir;
127
+ }
128
+ function isAtOrBelow(dir, root) {
129
+ const rel = relative(root, dir);
130
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
131
+ }
104
132
  function resolveSpecsDir(harnessDir, opts = {}) {
105
133
  const harness = resolve2(harnessDir);
106
134
  const repoRoot = dirname2(harness);
@@ -158,28 +186,27 @@ function scaffoldHarness(root) {
158
186
  }
159
187
  var GITIGNORE_SNIPPET = `# Morning Star harness (.mstar/)
160
188
  # Principle: process stays local; results are shared with the team.
161
- # Ignored (process / coordination):
162
- .mstar/archived/
163
- .mstar/iterations/
164
- .mstar/plans/
165
- .mstar/sdd/
166
- .mstar/notes.json
167
- .mstar/status.json
168
- # Tracked (results): .mstar/AGENTS.md, .mstar/knowledge/, .mstar/specs/
189
+ # Default-ignore everything under .mstar/, then re-include the tracked results.
190
+ .mstar/**
191
+ !.mstar/AGENTS.md
192
+ !.mstar/knowledge/
193
+ !.mstar/knowledge/**
194
+ !.mstar/specs/
195
+ !.mstar/specs/**
169
196
  `;
170
197
  var GITIGNORE_SNIPPET_AGENTS = `# Morning Star harness (.agents/) — legacy
171
- .agents/archived/
172
- .agents/iterations/
173
- .agents/plans/
174
- .agents/sdd/
175
- .agents/notes.json
176
- .agents/status.json
177
- # Tracked (results): .agents/AGENTS.md, .agents/knowledge/, .agents/specs/
198
+ # Default-ignore everything under .agents/, then re-include the tracked results.
199
+ .agents/**
200
+ !.agents/AGENTS.md
201
+ !.agents/knowledge/
202
+ !.agents/knowledge/**
203
+ !.agents/specs/
204
+ !.agents/specs/**
178
205
  `;
179
206
  var GITIGNORE_PROCESS_ENTRIES = GITIGNORE_SNIPPET.split(`
180
- `).filter((line) => line.startsWith(".mstar/")).map((line) => line.trim());
207
+ `).filter((line) => line.startsWith(".mstar/") || line.startsWith("!.mstar/")).map((line) => line.trim());
181
208
  var GITIGNORE_PROCESS_ENTRIES_AGENTS = GITIGNORE_SNIPPET_AGENTS.split(`
182
- `).filter((line) => line.startsWith(".agents/")).map((line) => line.trim());
209
+ `).filter((line) => line.startsWith(".agents/") || line.startsWith("!.agents/")).map((line) => line.trim());
183
210
  function emitGitignoreSnippet(kind) {
184
211
  if (kind === "agents")
185
212
  return GITIGNORE_SNIPPET_AGENTS;
@@ -230,7 +257,7 @@ function validateGitignore(root) {
230
257
  ok: true,
231
258
  severity: "low",
232
259
  code: "gitignore.ok",
233
- message: `.gitignore at ${gitignorePath} contains a complete canonical harness process-artifact ignore set (${label})`
260
+ message: `.gitignore at ${gitignorePath} contains a complete canonical harness ignore set — default-ignore + tracked re-includes (${label})`
234
261
  };
235
262
  }
236
263
  function detectHarnessKind(harnessDir) {
@@ -1151,7 +1178,7 @@ function techDebtRollup(docOrPath) {
1151
1178
  return { computed, stored, checks, overall };
1152
1179
  }
1153
1180
  // src/worktree.ts
1154
- import { execFileSync } from "node:child_process";
1181
+ import { execFileSync as execFileSync2 } from "node:child_process";
1155
1182
  import { existsSync as existsSync3 } from "node:fs";
1156
1183
  import { isAbsolute as isAbsolute3, resolve as resolve5 } from "node:path";
1157
1184
  var DEFAULT_PROBE_TIMEOUT_MS = 1e4;
@@ -1174,7 +1201,7 @@ function probeBranch(worktreePath, opts) {
1174
1201
  return { branch: precomputed };
1175
1202
  const timeout = opts.timeoutMs ?? probeTimeoutMs();
1176
1203
  try {
1177
- const stdout = execFileSync(opts.gitPath ?? "git", ["-C", worktreePath, "branch", "--show-current"], {
1204
+ const stdout = execFileSync2(opts.gitPath ?? "git", ["-C", worktreePath, "branch", "--show-current"], {
1178
1205
  encoding: "utf8",
1179
1206
  stdio: ["ignore", "pipe", "pipe"],
1180
1207
  timeout
@@ -1303,7 +1330,7 @@ function singleReviewSnapshot(assignments) {
1303
1330
  return gate(violations);
1304
1331
  }
1305
1332
  // src/sdd.ts
1306
- import { execFileSync as execFileSync2 } from "node:child_process";
1333
+ import { execFileSync as execFileSync3 } from "node:child_process";
1307
1334
  import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, realpathSync, statSync as statSync3, writeFileSync as writeFileSync3 } from "node:fs";
1308
1335
  import { basename as basename3, dirname as dirname4, isAbsolute as isAbsolute4, join as join5, resolve as resolve6 } from "node:path";
1309
1336
  class SddScriptError extends Error {
@@ -1331,7 +1358,7 @@ function isFile(file) {
1331
1358
  var GIT_CAPTURE_MAX_BYTES = 64 * 1024 * 1024;
1332
1359
  function gitOut(cwd, args) {
1333
1360
  try {
1334
- return execFileSync2("git", args, {
1361
+ return execFileSync3("git", args, {
1335
1362
  cwd,
1336
1363
  encoding: "utf8",
1337
1364
  stdio: ["ignore", "pipe", "pipe"],
@@ -1464,7 +1491,7 @@ function reviewPackage(base, head, outFile, opts = {}) {
1464
1491
  const cwd = opts.cwd ?? process.cwd();
1465
1492
  const verifyRef = (ref, what) => {
1466
1493
  try {
1467
- execFileSync2("git", ["rev-parse", "--verify", "--quiet", ref], { cwd, stdio: ["ignore", "pipe", "pipe"] });
1494
+ execFileSync3("git", ["rev-parse", "--verify", "--quiet", ref], { cwd, stdio: ["ignore", "pipe", "pipe"] });
1468
1495
  } catch {
1469
1496
  throw new SddScriptError(`bad ${what}: ${ref}`, 2);
1470
1497
  }
@@ -1484,7 +1511,7 @@ function reviewPackage(base, head, outFile, opts = {}) {
1484
1511
  const shortHead = gitOut(cwd, ["rev-parse", "--short", head]) ?? head;
1485
1512
  out = join5(sddDir, `review-${shortBase}..${shortHead}.diff`);
1486
1513
  }
1487
- const run = (args) => execFileSync2("git", args, { cwd, maxBuffer: GIT_CAPTURE_MAX_BYTES });
1514
+ const run = (args) => execFileSync3("git", args, { cwd, maxBuffer: GIT_CAPTURE_MAX_BYTES });
1488
1515
  const parts = [
1489
1516
  Buffer.from(`# Review package: ${base}..${head}
1490
1517
 
@@ -1508,7 +1535,7 @@ function assertBaseSha(ref, opts = {}) {
1508
1535
  throw new SddScriptError(`assertBaseSha: BASE must be a commit SHA (full or prefix); got ${JSON.stringify(ref)}. ` + "Never use HEAD~1 as review BASE (multi-commit tasks truncate).", 2);
1509
1536
  }
1510
1537
  try {
1511
- execFileSync2("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
1538
+ execFileSync3("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
1512
1539
  cwd: opts.cwd,
1513
1540
  stdio: ["ignore", "pipe", "pipe"]
1514
1541
  });
@@ -3378,6 +3405,8 @@ function detectHost(signals) {
3378
3405
  return "opencode";
3379
3406
  if (s.has("task_agent_batch") || s.has("ask") || s.has("hub"))
3380
3407
  return "omp";
3408
+ if (s.has("subagent"))
3409
+ return "dsh";
3381
3410
  if (s.has("AgentSwarm"))
3382
3411
  return "kimi";
3383
3412
  if (s.has("Agent") || s.has("AskUserQuestion") || s.has("EnterPlanMode") || s.has("TodoWrite"))
@@ -3401,9 +3430,10 @@ function resolveSkillRoot(host, paths) {
3401
3430
  case "kimi":
3402
3431
  case "zcode":
3403
3432
  return `./skills/${skill}${suffix}`;
3404
- case "pi":
3405
3433
  case "dsh":
3406
- return `deferred: ${host} has no plugin API in v1 — skill-root resolution lands with its adapter (roadmap §8.4)`;
3434
+ return `$DSH_BUNDLED_SKILL_DIR/${skill}${suffix}`;
3435
+ case "pi":
3436
+ return `deferred: pi has no plugin API in v1 — skill-root resolution lands with its adapter (roadmap §8.4)`;
3407
3437
  }
3408
3438
  }
3409
3439
  // src/skill-authoring.ts
package/dist/host.d.ts CHANGED
@@ -11,7 +11,10 @@
11
11
  * resolution.
12
12
  * - `.harness/references/skill-programmatic-roadmap.md` §8.4 — the
13
13
  * `HostAdapter` shared contract (all hooks optional; no concrete adapters
14
- * in the engine; pi/dsh deferred).
14
+ * in the engine; pi deferred).
15
+ * - `.harness/references/dsh-adapter-roadmap.md` §4 D5 (this iteration) —
16
+ * the dsh detection row + skill-root form; mirror text lands in the
17
+ * mstar-host skill when this module is upstreamed.
15
18
  *
16
19
  * UX judgment (ambiguous-host fallback reasoning, plan-mode bridges) stays
17
20
  * prompt — this module only turns tool shapes into a host id.
@@ -19,18 +22,19 @@
19
22
  import type { GateResult, ValidationResult } from "./core.js";
20
23
  import type { AssignmentFields } from "./dispatch.js";
21
24
  import type { IntegrationMergeLease } from "./lease.js";
22
- /** All hosts the engine knows about (roadmap §8.4 host union). `pi` and
23
- * `dsh` have no plugin API in v1 — they appear in the union (and in
24
- * `HostAdapter.host`) but are never detected and get no adapters. */
25
+ /** All hosts the engine knows about (roadmap §8.4 host union). `pi` has no
26
+ * plugin API in v1 — it appears in the union (and in `HostAdapter.host`) but
27
+ * is never detected and gets no adapter. `dsh` is detected and resolved
28
+ * (roadmap §4 D5, this iteration); its adapter ships in the dsh plugin. */
25
29
  export type HostId = "opencode" | "omp" | "pi" | "dsh" | "cursor" | "codex" | "kimi" | "zcode";
26
- /** Result of `detectHost`: one of the six known hosts or `ambiguous`
30
+ /** Result of `detectHost`: one of the seven known hosts or `ambiguous`
27
31
  * (prompt judgment then applies per mstar-host). */
28
- export type DetectResult = "opencode" | "omp" | "cursor" | "codex" | "kimi" | "zcode" | "ambiguous";
32
+ export type DetectResult = "opencode" | "omp" | "dsh" | "cursor" | "codex" | "kimi" | "zcode" | "ambiguous";
29
33
  /** Tool-shape signal tokens accepted by `detectHost`, derived from the
30
34
  * mstar-host detection table. Plan-mode extras (CreatePlan/SwitchMode) and
31
35
  * Browser-plugin tools are documented in the table but are not part of the
32
36
  * v1 signal enum. */
33
- export type ToolSignal = "subagent_type" | "question" | "task_subagent" | "task_agent_batch" | "ask" | "hub" | "Agent" | "AgentSwarm" | "AskUserQuestion" | "EnterPlanMode" | "TodoWrite" | "plan_slash" | "goal" | "functions.*" | "tool_search";
37
+ export type ToolSignal = "subagent_type" | "question" | "task_subagent" | "task_agent_batch" | "ask" | "hub" | "subagent" | "Agent" | "AgentSwarm" | "AskUserQuestion" | "EnterPlanMode" | "TodoWrite" | "plan_slash" | "goal" | "functions.*" | "tool_search";
34
38
  /**
35
39
  * Detect the active host from session tool shapes, per the ordered
36
40
  * mstar-host table (ported verbatim):
@@ -40,14 +44,19 @@ export type ToolSignal = "subagent_type" | "question" | "task_subagent" | "task_
40
44
  * | `subagent_type` (Task param; plan mode + CreatePlan/SwitchMode) | cursor |
41
45
  * | `question`, or `task_subagent` (task tool, singular subagent, no batch) | opencode |
42
46
  * | `task_agent_batch` (task tool, agent/tasks[] batch), `ask`, `hub` | omp |
47
+ * | `subagent` (dsh's model-facing delegation tool) | dsh |
43
48
  * | `Agent`/`AskUserQuestion`/`EnterPlanMode` + `AgentSwarm` (Kimi-only) | kimi |
44
49
  * | `Agent`/`AskUserQuestion`/`EnterPlanMode`/`TodoWrite`, no `AgentSwarm` | zcode |
45
50
  * | `/plan`, `/goal`; Goal tools; `functions.*` namespaces; `tool_search` | codex |
46
51
  *
47
- * Order matters: cursor → opencode → omp → kimi → zcode → codex — the
48
- * sharpest Task-based split is `subagent_type` (Cursor) vs `subagent`
49
- * (OpenCode) vs `agent`/`tasks[]` (omp). Still ambiguous `"ambiguous"`
50
- * (prompt judgment stays in the skill).
52
+ * Order matters: cursor → opencode → omp → dsh → kimi → zcode → codex — the
53
+ * sharpest Task-based split is `subagent_type` (Cursor) vs `task_subagent`
54
+ * (OpenCode) vs `agent`/`tasks[]` (omp); dsh's `subagent` tool (roadmap §4
55
+ * D5) collides with no other row, so it sits with the agent-tool hosts.
56
+ * (`mstar-host` skill prose still says colloquial `subagent` for OpenCode —
57
+ * disambiguate it to `task_subagent` in the same upstream PR that carries
58
+ * this row, together with the CLI `HOST_SIGNALS` list.)
59
+ * Still ambiguous → `"ambiguous"` (prompt judgment stays in the skill).
51
60
  */
52
61
  export declare function detectHost(signals: readonly ToolSignal[]): DetectResult;
53
62
  /** Skill name + optional skill-relative path for skill-root resolution. */
@@ -69,7 +78,8 @@ export type SkillRootPaths = {
69
78
  * | codex | `skills/<name>[/<rel>]` (plugin-mounted; project command skills under `.agents/skills/<name>/`) |
70
79
  * | opencode | `harness-skills/<name>[/<rel>]` (package-internal via `@mstar-harness/opencode` — never `process.cwd()/skills/`) |
71
80
  * | kimi / zcode | `./skills/<name>[/<rel>]` (plugin mount from the installed plugin root) |
72
- * | pi / dsh | deferredno plugin API in v1 (roadmap §8.4) |
81
+ * | dsh | `$DSH_BUNDLED_SKILL_DIR/<name>[/<rel>]` (skill-local bundled root dsh-skill-local `bundledSkillDir` default; the single canonical mount per roadmap D6. Frozen this iteration; local dev mounts the mirror `skills/` via `customSkillDirs` instead, but the canonical published form stays the bundled root) |
82
+ * | pi | deferred — no plugin API in v1 (roadmap §8.4) |
73
83
  */
74
84
  export declare function resolveSkillRoot(host: HostId, paths: SkillRootPaths): string;
75
85
  /**
@@ -79,8 +89,8 @@ export declare function resolveSkillRoot(host: HostId, paths: SkillRootPaths): s
79
89
  * rule: skill text remains authoritative; the engine only returns results
80
90
  * the caller chooses to honor). `log` is required — adapters must be able to
81
91
  * report. No concrete adapters ship in the engine: opencode binds via its
82
- * own plugin code, omp via the command layer; pi/dsh adapters are deferred
83
- * until their plugin APIs land.
92
+ * own plugin code, omp via the command layer, dsh via the dsh plugin (this
93
+ * iteration); pi stays deferred until its plugin API lands.
84
94
  */
85
95
  export interface HostAdapter {
86
96
  host: "opencode" | "omp" | "pi" | "dsh" | "cursor" | "codex" | "kimi" | "zcode";
package/dist/path.d.ts CHANGED
@@ -10,15 +10,32 @@ export type ResolveHarnessDirOptions = {
10
10
  * (the caller may scaffold it).
11
11
  */
12
12
  harnessDir?: string;
13
+ /**
14
+ * Workspace-root stop boundary (roadmap §7c / plan
15
+ * 20260810-harness-root-boundary). The upward probe keeps walking only
16
+ * while `dir` is at or below this root — a harness dir above it is never
17
+ * returned (the `~/.mstar` global-collision defect is the special case).
18
+ * Resolved against `startDir` when relative. When omitted, the default
19
+ * boundary is the git top-level of `startDir` (sync `git rev-parse
20
+ * --show-cdup`; on failure / non-git start it falls back to
21
+ * `startDir` itself — a non-git start probes only itself, never upward;
22
+ * deliberate tightening). The boundary is an explicit caller value: the
23
+ * engine git-probes only for this default resolution, never during the
24
+ * walk.
25
+ */
26
+ workspaceRoot?: string;
13
27
  };
14
28
  /**
15
29
  * Resolve `{HARNESS_DIR}` per plan-conventions § {HARNESS_DIR} 解析顺序
16
30
  * (find-first-stop): `.mstar/` → `.agents/` → `.plans/`/`plans/`, walking up
17
- * from `startDir`. Harness candidates are dir-existence (the empty-dir rule
18
- * applies to `{SPECS_DIR}` only). An explicit override via `opts.harnessDir`
19
- * or `MSTAR_HARNESS_DIR` wins over probing.
31
+ * from `startDir` but NEVER above the workspace root (`opts.workspaceRoot`,
32
+ * default = git top-level of `startDir`). Harness candidates are
33
+ * dir-existence (the empty-dir rule applies to `{SPECS_DIR}` only). An
34
+ * explicit override via `opts.harnessDir` or `MSTAR_HARNESS_DIR` wins over
35
+ * probing and short-circuits before any boundary logic.
20
36
  *
21
- * Returns the absolute harness dir, or `null` when no candidate exists.
37
+ * Returns the absolute harness dir, or `null` when no candidate exists
38
+ * within the workspace boundary.
22
39
  */
23
40
  export declare function resolveHarnessDir(startDir?: string, opts?: ResolveHarnessDirOptions): string | null;
24
41
  /**
@@ -86,15 +103,17 @@ export declare function scaffoldHarness(root: string): string;
86
103
  export type HarnessKind = "mstar" | "agents";
87
104
  /**
88
105
  * Emit the canonical `.gitignore` snippet for `kind` (plan-conventions
89
- * § Git 跟踪策略): the process-artifact ignore set (`archived/`,
90
- * `iterations/`, `plans/`, `sdd/`, `notes.json`, `status.json` under the
91
- * harness dir) with the tracked/results note. When the kind is unknown
92
- * (omitted), both snippets are emitted so either fence can be applied.
106
+ * § Git 跟踪策略): default-ignore the whole harness dir (`<dir>/**`) and
107
+ * re-include only the tracked results (AGENTS.md, knowledge/, specs/).
108
+ * When the kind is unknown (omitted), both snippets are emitted so either
109
+ * fence can be applied.
93
110
  */
94
111
  export declare function emitGitignoreSnippet(kind?: HarnessKind): string;
95
112
  /**
96
113
  * Validate that `<root>/.gitignore` contains a complete canonical
97
- * process-artifact ignore set (plan-conventions § Git 跟踪策略). Rule
114
+ * harness ignore set — default-ignore `<dir>/**` plus the tracked
115
+ * re-includes (AGENTS.md, knowledge/, specs/) per plan-conventions
116
+ * § Git 跟踪策略. Rule
98
117
  * (chosen alignment): the gate passes when the repo's .gitignore holds ONE
99
118
  * complete set for the DETECTED harness kind — `.mstar/` for a `.mstar`
100
119
  * harness, `.agents/` for a legacy `.agents` harness; layouts without a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mstar-harness/engine",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Morning Star Harness Workflow Engine — deterministic workflow enforcement library (path, status, lease, dispatch, sdd, iteration, lint gates).",
5
5
  "license": "MIT",
6
6
  "repository": {