@llman-sdd/cli 0.7.0 → 0.7.1

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": "@llman-sdd/cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Spec-driven development workflow CLI (the `llman-sdd` command)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,7 +27,7 @@
27
27
  "build": "bun run ./scripts/build-binary.ts"
28
28
  },
29
29
  "dependencies": {
30
- "@llman-sdd/core": "0.7.0",
30
+ "@llman-sdd/core": "0.7.1",
31
31
  "commander": "^15.0.0"
32
32
  },
33
33
  "engines": {
@@ -14,6 +14,7 @@ import type { Command } from 'commander';
14
14
 
15
15
  import { CliError } from '../cli-shared.ts';
16
16
  import { makeCliGit, makeIo } from '../io.ts';
17
+ import { progressNote } from '../progress.ts';
17
18
 
18
19
  /**
19
20
  * r24: warn (never block) when freeze/thaw runs outside the main checkout —
@@ -69,6 +70,8 @@ export function registerArchive(program: Command): void {
69
70
  for (const line of await runList(io, sz, root)) console.log(line);
70
71
  return;
71
72
  }
73
+ // 7z 压缩可能耗时 — 进度提示防止看上去卡住(B1);dry-run/list 不走压缩
74
+ if (!options.dryRun) progressNote('freeze.pack', root);
72
75
  const result = await runFreeze(io, sz, root, {
73
76
  before: options.before,
74
77
  keepRecent: Number(options.keepRecent),
@@ -105,6 +108,8 @@ export function registerArchive(program: Command): void {
105
108
  const sz = await makeEmbeddedSevenZip();
106
109
  const io = makeIo(root);
107
110
  try {
111
+ // 7z 解包可能耗时 — 进度提示防止看上去卡住(B1)
112
+ progressNote('thaw.unpack', root);
108
113
  const result = await runThaw(io, sz, root, options.change, { dest: options.dest });
109
114
  for (const line of result.lines) console.log(line);
110
115
  } catch (error) {
@@ -1,5 +1,5 @@
1
1
  import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
2
- import { resolve } from 'node:path';
2
+ import { basename, resolve } from 'node:path';
3
3
 
4
4
  import {
5
5
  CLOSE_OUT_TASK_HINT,
@@ -291,9 +291,9 @@ export function registerChange(program: Command): void {
291
291
  skipCleanTree: true,
292
292
  today: new Date().toISOString().slice(0, 10),
293
293
  });
294
- const archiveName = (result.archiveDir ?? '').slice(
295
- (result.archiveDir ?? '').lastIndexOf('/') + 1,
296
- );
294
+ // node:path.basename — platform-aware (win32 backslashes would defeat
295
+ // a hand-rolled lastIndexOf('/')).
296
+ const archiveName = basename(result.archiveDir ?? '');
297
297
  console.log(`Change '${id}' archived as '${archiveName}'.`);
298
298
  if (result.executedIn !== null) {
299
299
  console.log(`executed in target worktree ${result.executedIn}`);
@@ -9,6 +9,7 @@ import {
9
9
  import type { Command } from 'commander';
10
10
 
11
11
  import { CliError, loadSpecEntries, newIo, resolveBackend } from '../cli-shared.ts';
12
+ import { progressNote } from '../progress.ts';
12
13
 
13
14
  // (err path below converted to CliError; resolveBackend may also throw CliError)
14
15
 
@@ -45,6 +46,8 @@ export function registerContext(program: Command): void {
45
46
  console.log(JSON.stringify(unavailableResult(), null, 2));
46
47
  return;
47
48
  }
49
+ // LLM 链路可能是秒级 — 进度提示防止看上去卡住(B1)
50
+ progressNote('context.retrieve', process.cwd());
48
51
  const result = await runContextRetrieval({
49
52
  config,
50
53
  task: options.task ?? '',
@@ -6,6 +6,7 @@ import type { Command } from 'commander';
6
6
 
7
7
  import { CliError, templateIo, version } from '../cli-shared.ts';
8
8
  import { makeCliGit, makeIo } from '../io.ts';
9
+ import { progressNote } from '../progress.ts';
9
10
 
10
11
  /**
11
12
  * r93: the repo-root instance (git toplevel, or outside any repo) carries the
@@ -43,6 +44,8 @@ export function registerInit(program: Command): void {
43
44
  mkdirSync(root, { recursive: true });
44
45
  }
45
46
  const repoRootInstance = isRepoRootInstance(root);
47
+ // 模板生成/子根扫块可能耗时 — 进度提示防止看上去卡住(B1)
48
+ progressNote('init.generate', root);
46
49
  const result = runInit(makeIo(root), templateIo, {
47
50
  update: options.update ?? false,
48
51
  locale: options.locale ?? options.lang,
@@ -319,24 +319,28 @@ function warnDirtySpecsOnDefaultBranch(): void {
319
319
  let harnessBannerShown = false;
320
320
 
321
321
  /**
322
- * r13/r48 trigger state: the nested guard env and the check flags are read
322
+ * r13/r48 trigger state: the nested guard env and the check flag are read
323
323
  * here (CLI boundary) and injected into core as parameters — core harness
324
- * logic never touches process.env. Commander tri-state (both flags declared):
325
- * --check → check=true, --no-check → check=false, absent → undefined.
324
+ * logic never touches process.env. Commander binary state:
325
+ * --check → check=true, absent → undefined.
326
326
  */
327
327
  function makeHarnessGate(options: { check?: boolean }): HarnessGate {
328
328
  const runCommand = loadCliConfig()?.specs?.check_command ?? null;
329
329
  const configured = runCommand !== null && runCommand !== '';
330
330
  return {
331
331
  nested: process.env.LLMAN_SDD_HARNESS_ACTIVE === '1',
332
- check: options.check === false ? 'off' : options.check === true ? 'on' : 'default',
332
+ // Opt-in harness (2026-10 decision): absent → 'off' — structure/state gate
333
+ // only; --check → 'on' (explicit full harness). The CLI never sends
334
+ // 'default'; core still treats a received 'default' as
335
+ // run-if-configured (its own contract).
336
+ check: options.check === true ? 'on' : 'off',
333
337
  runner: configured ? makeCliHarnessRunner() : undefined,
334
338
  runCommand,
335
339
  cwd: process.cwd(),
336
340
  onBeforeFirstRun: (expanded: string): void => {
337
341
  if (harnessBannerShown) return;
338
342
  harnessBannerShown = true;
339
- console.error(`running spec check: ${expanded} (use --no-check to skip)`);
343
+ console.error(`running spec check: ${expanded} (explicit --check harness run)`);
340
344
  },
341
345
  };
342
346
  }
@@ -361,8 +365,7 @@ export function registerValidate(program: Command): void {
361
365
  'aggregate: validate every discovered llmanspec root (specs scope, any root red → non-zero exit)',
362
366
  )
363
367
  .option('--include-info', 'keep INFO-level issues (default: WARNING and above)')
364
- .option('--no-check', 'skip the spec check')
365
- .option('--check', 'run the spec check (default when specs.check_command is configured)');
368
+ .option('--check', 'run the spec check (full harness) explicitly — opt-in, default skips');
366
369
  addReportOutputOptions(validate);
367
370
  validate.action(
368
371
  (
package/src/io.ts CHANGED
@@ -14,10 +14,20 @@ import {
14
14
  statSync,
15
15
  writeFileSync,
16
16
  } from 'node:fs';
17
- import { isAbsolute, join } from 'node:path';
17
+ import { dirname, isAbsolute, join } from 'node:path';
18
18
 
19
19
  import { makeSpawnGit, type GitLike } from '@llman-sdd/core';
20
20
 
21
+ /**
22
+ * Platform-aware parent directory (node:path.dirname, never hand-rolled
23
+ * '/' slicing): on win32, join() yields backslashes and a lastIndexOf('/')
24
+ * fallback would resolve to '.', so mkdir of a nested new dir would fail with
25
+ * ENOENT. Exported for unit-test guards (A2 platform compat).
26
+ */
27
+ export function parentOf(p: string): string {
28
+ return dirname(p);
29
+ }
30
+
21
31
  export interface CliIo {
22
32
  exists(path: string): boolean;
23
33
  readText(path: string): string;
@@ -37,7 +47,6 @@ export interface CliIo {
37
47
 
38
48
  export function makeIo(root: string): CliIo {
39
49
  const full = (p: string): string => (isAbsolute(p) ? p : join(root, p));
40
- const parentOf = (p: string): string => p.slice(0, p.lastIndexOf('/')) || '.';
41
50
  return {
42
51
  exists: (p) => existsSync(full(p)),
43
52
  readText: (p) => readFileSync(full(p), 'utf8'),
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Localized stage-progress notes (B1): genuinely long-running operations print
3
+ * a short one-line stderr note in the project locale (zh → zh-Hans text,
4
+ * everything else English) so neither humans nor agents mistake progress for a
5
+ * hang. Progress ALWAYS goes to stderr (capture contract: results on stdout,
6
+ * progress on stderr); each key fires at most once per process; notes are only
7
+ * emitted before the long op itself, never ahead of domain errors.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ type Locale = 'zh' | 'en';
13
+
14
+ const NOTES: Record<string, Record<Locale, string>> = {
15
+ 'context.retrieve': {
16
+ zh: '正在检索 specs 上下文(LLM)...',
17
+ en: 'Retrieving spec context (LLM)...',
18
+ },
19
+ 'init.generate': {
20
+ zh: '正在初始化 llmanspec/ 与 skills...',
21
+ en: 'Initializing llmanspec/ and skills...',
22
+ },
23
+ 'freeze.pack': {
24
+ zh: '正在压缩冻结归档(7z)...',
25
+ en: 'Packing freeze archive (7z)...',
26
+ },
27
+ 'thaw.unpack': {
28
+ zh: '正在解包冻结归档(7z)...',
29
+ en: 'Unpacking freeze archive (7z)...',
30
+ },
31
+ };
32
+
33
+ /** Resolve the note text for a locale (pure; exported for unit tests). */
34
+ export function resolveNote(key: string, locale: Locale): string | undefined {
35
+ return NOTES[key]?.[locale];
36
+ }
37
+
38
+ /** Detect the project locale from llmanspec/config.yaml (zh-* → zh, else en). */
39
+ export function detectLocale(cwd: string): Locale {
40
+ try {
41
+ const cfg = readFileSync(join(cwd, 'llmanspec', 'config.yaml'), 'utf8');
42
+ const m = cfg.match(/^locale:\s*([A-Za-z-]+)/mu);
43
+ if (m?.[1]?.startsWith('zh') ?? false) return 'zh';
44
+ } catch {
45
+ // No config at cwd — English fallback.
46
+ }
47
+ return 'en';
48
+ }
49
+
50
+ const shown = new Set<string>();
51
+
52
+ /** Print a localized progress note to stderr once per key per process. */
53
+ export function progressNote(key: string, cwd: string = process.cwd()): void {
54
+ if (shown.has(key)) return;
55
+ const text = resolveNote(key, detectLocale(cwd));
56
+ if (text === undefined) return;
57
+ shown.add(key);
58
+ process.stderr.write(`${text}\n`);
59
+ }