@principles/pd-cli 1.135.0 → 1.135.2

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 (74) hide show
  1. package/dist/commands/legacy-cleanup.d.ts.map +1 -1
  2. package/dist/commands/legacy-cleanup.js +19 -2
  3. package/dist/commands/legacy-cleanup.js.map +1 -1
  4. package/dist/commands/pain-evidence.d.ts +3 -1
  5. package/dist/commands/pain-evidence.d.ts.map +1 -1
  6. package/dist/commands/pain-evidence.js +12 -3
  7. package/dist/commands/pain-evidence.js.map +1 -1
  8. package/dist/commands/rulecode.d.ts +13 -0
  9. package/dist/commands/rulecode.d.ts.map +1 -1
  10. package/dist/commands/rulecode.js +23 -2
  11. package/dist/commands/rulecode.js.map +1 -1
  12. package/dist/commands/runtime-activation.d.ts.map +1 -1
  13. package/dist/commands/runtime-activation.js +36 -7
  14. package/dist/commands/runtime-activation.js.map +1 -1
  15. package/dist/commands/runtime-internalization-enqueue-successors.d.ts.map +1 -1
  16. package/dist/commands/runtime-internalization-enqueue-successors.js +48 -0
  17. package/dist/commands/runtime-internalization-enqueue-successors.js.map +1 -1
  18. package/dist/commands/runtime-internalization-retry.d.ts +38 -0
  19. package/dist/commands/runtime-internalization-retry.d.ts.map +1 -0
  20. package/dist/commands/runtime-internalization-retry.js +143 -0
  21. package/dist/commands/runtime-internalization-retry.js.map +1 -0
  22. package/dist/index.js +11 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/resolve-workspace.d.ts.map +1 -1
  25. package/dist/resolve-workspace.js +33 -12
  26. package/dist/resolve-workspace.js.map +1 -1
  27. package/dist/services/__tests__/evaluator-runner-deps.test.js +19 -7
  28. package/dist/services/__tests__/evaluator-runner-deps.test.js.map +1 -1
  29. package/dist/services/console-launcher.d.ts +8 -1
  30. package/dist/services/console-launcher.d.ts.map +1 -1
  31. package/dist/services/console-launcher.js +45 -3
  32. package/dist/services/console-launcher.js.map +1 -1
  33. package/dist/services/pd-config-loader.d.ts.map +1 -1
  34. package/dist/services/pd-config-loader.js +34 -1
  35. package/dist/services/pd-config-loader.js.map +1 -1
  36. package/dist/services/quality-scorecard/strong-model-gate.d.ts +19 -0
  37. package/dist/services/quality-scorecard/strong-model-gate.d.ts.map +1 -1
  38. package/dist/services/quality-scorecard/strong-model-gate.js +44 -2
  39. package/dist/services/quality-scorecard/strong-model-gate.js.map +1 -1
  40. package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
  41. package/dist/services/rulehost-pipeline-runner.js +6 -2
  42. package/dist/services/rulehost-pipeline-runner.js.map +1 -1
  43. package/dist/utils/path-security.d.ts +60 -0
  44. package/dist/utils/path-security.d.ts.map +1 -0
  45. package/dist/utils/path-security.js +90 -0
  46. package/dist/utils/path-security.js.map +1 -0
  47. package/package.json +1 -1
  48. package/src/commands/legacy-cleanup.ts +19 -2
  49. package/src/commands/pain-evidence.ts +11 -3
  50. package/src/commands/rulecode.ts +25 -2
  51. package/src/commands/runtime-activation.ts +38 -6
  52. package/src/commands/runtime-internalization-enqueue-successors.ts +48 -0
  53. package/src/commands/runtime-internalization-retry.ts +163 -0
  54. package/src/index.ts +12 -0
  55. package/src/resolve-workspace.ts +41 -17
  56. package/src/services/__tests__/evaluator-runner-deps.test.ts +20 -8
  57. package/src/services/console-launcher.ts +45 -3
  58. package/src/services/pd-config-loader.ts +35 -1
  59. package/src/services/quality-scorecard/strong-model-gate.ts +44 -2
  60. package/src/services/rulehost-pipeline-runner.ts +5 -2
  61. package/src/utils/path-security.ts +96 -0
  62. package/tests/commands/cli-command-tree.test.ts +15 -0
  63. package/tests/commands/legacy-cleanup.test.ts +148 -0
  64. package/tests/commands/pain-evidence.test.ts +37 -0
  65. package/tests/commands/pri-393-runtime-config-unification.test.ts +5 -1
  66. package/tests/commands/product-path-regression.test.ts +9 -4
  67. package/tests/commands/rulecode.test.ts +135 -0
  68. package/tests/commands/runtime-diagnostics-export.test.ts +6 -2
  69. package/tests/commands/runtime-internalization-retry-owner-authority.test.ts +431 -0
  70. package/tests/resolve-workspace.test.ts +21 -0
  71. package/tests/services/console-launcher.test.ts +114 -0
  72. package/tests/services/pd-config-loader.test.ts +8 -1
  73. package/tests/services/quality-scorecard/strong-model-gate.test.ts +133 -0
  74. package/tests/utils/path-security.test.ts +180 -0
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Path containment primitives (CWE-22 boundary guards).
3
+ *
4
+ * Single source of truth for "is this filesystem target inside that root?"
5
+ * across pd-cli security boundaries. All containment decisions compare
6
+ * CANONICAL (fully resolved) paths via `path.relative`, never by string
7
+ * prefix — a string `startsWith` on a possibly-relative root is wrong on
8
+ * two counts: (1) a relative root never prefixes an absolute target, and
9
+ * (2) `/work/foo` is a prefix of `/work/foobar` without being a boundary.
10
+ *
11
+ * ── Symlink policy ────────────────────────────────────────────────────────
12
+ * The guarantee provided here is LEXICAL containment: `path.resolve` +
13
+ * `path.relative`, without resolving symlinks. We deliberately do NOT
14
+ * `realpath` the target before containment because:
15
+ * 1. PD's IO roots are operator-supplied workspace directories; symlinks
16
+ * inside the workspace are created by the owner and treated as trusted
17
+ * content.
18
+ * 2. On Windows, junction points (worktree junctions, `node_modules`
19
+ * junctions) resolve to a *different physical location* via `realpath`;
20
+ * realpath-based containment would reject legitimate local workflows
21
+ * (e.g. a worktree whose `node_modules` is junctioned to the main
22
+ * checkout).
23
+ * If a future caller must constrain the physical read target (e.g. reading a
24
+ * file whose path could be a symlink to an untrusted location), that caller
25
+ * must realpath the target FIRST and then run containment on the resolved
26
+ * path — do not weaken this module's contract.
27
+ */
28
+ /**
29
+ * Canonicalize a user/operator-supplied path once. Every derived filesystem
30
+ * target must be compared against this canonical root.
31
+ */
32
+ export declare function canonicalPath(p: string): string;
33
+ /**
34
+ * True when `candidate` is strictly inside `parent` (canonical comparison).
35
+ *
36
+ * - Both arguments are resolved against cwd first, so relative inputs work.
37
+ * - `candidate === parent` returns false (strict containment). Callers that
38
+ * want to allow the root itself should check equality separately.
39
+ * - Sibling-prefix attacks (`/work/foobar` vs parent `/work/foo`) cannot
40
+ * pass because `path.relative` yields a non-`..`-prefixed path only for
41
+ * real descendants.
42
+ */
43
+ export declare function isPathInside(parent: string, candidate: string): boolean;
44
+ /**
45
+ * Throw unless `candidate` is strictly inside `parent`. `label` names the
46
+ * candidate in the error message (e.g. "--workspace").
47
+ */
48
+ export declare function assertPathInside(parent: string, candidate: string, label: string): void;
49
+ /**
50
+ * Validate an operator-supplied directory root before it is used as an IO
51
+ * root: rejects empty values, residual parent-traversal segments, and
52
+ * filesystem-root results. Returns the canonical root.
53
+ *
54
+ * No `path.isAbsolute` requirement: absolute-ness is platform-dependent (a
55
+ * Windows-style path like `Z:\work` is not absolute on POSIX runners) and
56
+ * relative paths resolve inside cwd, so they carry no traversal risk. The
57
+ * guards that matter are: empty, parent traversal, and filesystem root.
58
+ */
59
+ export declare function assertSafeDirectoryRoot(input: string, label: string): string;
60
+ //# sourceMappingURL=path-security.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"path-security.d.ts","sourceRoot":"","sources":["../../src/utils/path-security.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAIH;;;GAGG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAUvE;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAIvF;AAED;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAe5E"}
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Path containment primitives (CWE-22 boundary guards).
3
+ *
4
+ * Single source of truth for "is this filesystem target inside that root?"
5
+ * across pd-cli security boundaries. All containment decisions compare
6
+ * CANONICAL (fully resolved) paths via `path.relative`, never by string
7
+ * prefix — a string `startsWith` on a possibly-relative root is wrong on
8
+ * two counts: (1) a relative root never prefixes an absolute target, and
9
+ * (2) `/work/foo` is a prefix of `/work/foobar` without being a boundary.
10
+ *
11
+ * ── Symlink policy ────────────────────────────────────────────────────────
12
+ * The guarantee provided here is LEXICAL containment: `path.resolve` +
13
+ * `path.relative`, without resolving symlinks. We deliberately do NOT
14
+ * `realpath` the target before containment because:
15
+ * 1. PD's IO roots are operator-supplied workspace directories; symlinks
16
+ * inside the workspace are created by the owner and treated as trusted
17
+ * content.
18
+ * 2. On Windows, junction points (worktree junctions, `node_modules`
19
+ * junctions) resolve to a *different physical location* via `realpath`;
20
+ * realpath-based containment would reject legitimate local workflows
21
+ * (e.g. a worktree whose `node_modules` is junctioned to the main
22
+ * checkout).
23
+ * If a future caller must constrain the physical read target (e.g. reading a
24
+ * file whose path could be a symlink to an untrusted location), that caller
25
+ * must realpath the target FIRST and then run containment on the resolved
26
+ * path — do not weaken this module's contract.
27
+ */
28
+ import * as path from 'node:path';
29
+ /**
30
+ * Canonicalize a user/operator-supplied path once. Every derived filesystem
31
+ * target must be compared against this canonical root.
32
+ */
33
+ export function canonicalPath(p) {
34
+ return path.resolve(p);
35
+ }
36
+ /**
37
+ * True when `candidate` is strictly inside `parent` (canonical comparison).
38
+ *
39
+ * - Both arguments are resolved against cwd first, so relative inputs work.
40
+ * - `candidate === parent` returns false (strict containment). Callers that
41
+ * want to allow the root itself should check equality separately.
42
+ * - Sibling-prefix attacks (`/work/foobar` vs parent `/work/foo`) cannot
43
+ * pass because `path.relative` yields a non-`..`-prefixed path only for
44
+ * real descendants.
45
+ */
46
+ export function isPathInside(parent, candidate) {
47
+ const root = path.resolve(parent);
48
+ const target = path.resolve(candidate);
49
+ const rel = path.relative(root, target);
50
+ return (rel !== '' &&
51
+ rel !== '..' &&
52
+ !rel.startsWith(`..${path.sep}`) &&
53
+ !path.isAbsolute(rel));
54
+ }
55
+ /**
56
+ * Throw unless `candidate` is strictly inside `parent`. `label` names the
57
+ * candidate in the error message (e.g. "--workspace").
58
+ */
59
+ export function assertPathInside(parent, candidate, label) {
60
+ if (!isPathInside(parent, candidate)) {
61
+ throw new Error(`Invalid ${label}: "${candidate}" is outside "${parent}"`);
62
+ }
63
+ }
64
+ /**
65
+ * Validate an operator-supplied directory root before it is used as an IO
66
+ * root: rejects empty values, residual parent-traversal segments, and
67
+ * filesystem-root results. Returns the canonical root.
68
+ *
69
+ * No `path.isAbsolute` requirement: absolute-ness is platform-dependent (a
70
+ * Windows-style path like `Z:\work` is not absolute on POSIX runners) and
71
+ * relative paths resolve inside cwd, so they carry no traversal risk. The
72
+ * guards that matter are: empty, parent traversal, and filesystem root.
73
+ */
74
+ export function assertSafeDirectoryRoot(input, label) {
75
+ if (!input || input.trim().length === 0) {
76
+ throw new Error(`Invalid ${label}: path is empty`);
77
+ }
78
+ // Un-normalized `..` segments that survive normalize() mean the input
79
+ // escaped a parent boundary (e.g. "..\\..\\evil") — reject rather than
80
+ // trust them. Foldable segments ("a/../b") canonicalize safely.
81
+ if (path.normalize(input).split(/[\\/]/).includes('..')) {
82
+ throw new Error(`Invalid ${label}: "${input}" contains parent traversal`);
83
+ }
84
+ const root = canonicalPath(input);
85
+ if (root === path.parse(root).root) {
86
+ throw new Error(`Invalid ${label}: "${input}" resolves to filesystem root`);
87
+ }
88
+ return root;
89
+ }
90
+ //# sourceMappingURL=path-security.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"path-security.js","sourceRoot":"","sources":["../../src/utils/path-security.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,CAAS;IACrC,OAAO,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AACzB,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,YAAY,CAAC,MAAc,EAAE,SAAiB;IAC5D,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAClC,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IACvC,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACxC,OAAO,CACL,GAAG,KAAK,EAAE;QACV,GAAG,KAAK,IAAI;QACZ,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,IAAI,CAAC,GAAG,EAAE,CAAC;QAChC,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CACtB,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAc,EAAE,SAAiB,EAAE,KAAa;IAC/E,IAAI,CAAC,YAAY,CAAC,MAAM,EAAE,SAAS,CAAC,EAAE,CAAC;QACrC,MAAM,IAAI,KAAK,CAAC,WAAW,KAAK,MAAM,SAAS,iBAAiB,MAAM,GAAG,CAAC,CAAC;IAC7E,CAAC;AACH,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,uBAAuB,CAAC,KAAa,EAAE,KAAa;IAClE,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxC,MAAM,IAAI,KAAK,CAAC,WAAW,KAAK,iBAAiB,CAAC,CAAC;IACrD,CAAC;IACD,sEAAsE;IACtE,uEAAuE;IACvE,gEAAgE;IAChE,IAAI,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QACxD,MAAM,IAAI,KAAK,CAAC,WAAW,KAAK,MAAM,KAAK,6BAA6B,CAAC,CAAC;IAC5E,CAAC;IACD,MAAM,IAAI,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAClC,IAAI,IAAI,KAAK,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC;QACnC,MAAM,IAAI,KAAK,CAAC,WAAW,KAAK,MAAM,KAAK,+BAA+B,CAAC,CAAC;IAC9E,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/pd-cli",
3
- "version": "1.135.0",
3
+ "version": "1.135.2",
4
4
  "description": "PD CLI — Pain recording, sample management, and evolution tasks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@ import * as path from 'path';
27
27
  import * as os from 'os';
28
28
  import type { Database } from 'better-sqlite3';
29
29
  import { RuntimeStateManager } from '@principles/core/runtime-v2';
30
+ import { assertSafeDirectoryRoot, isPathInside } from '../utils/path-security.js';
30
31
 
31
32
  // ── Types ────────────────────────────────────────────────────────────────────
32
33
 
@@ -257,9 +258,21 @@ function glob(pattern: string): string[] {
257
258
  return results;
258
259
  }
259
260
 
261
+ /**
262
+ * Validate a workspace root before cleanup scans derive paths from it.
263
+ * Returns the canonical root so all derived targets are absolute and
264
+ * containment checks are canonical-vs-canonical (relative workspaces work).
265
+ */
266
+ function assertCleanupWorkspaceRoot(workspacePath: string): string {
267
+ return assertSafeDirectoryRoot(workspacePath, 'workspace path');
268
+ }
269
+
260
270
  function findLegacyTargets(workspacePath: string): CleanupTarget[] {
261
271
  const targets: CleanupTarget[] = [];
262
- const stateDir = path.join(workspacePath, '.state');
272
+ // CWE-22: resolve the workspace root once and verify it is a valid,
273
+ // non-root directory so every derived path stays inside the boundary.
274
+ const workspaceRoot = assertCleanupWorkspaceRoot(workspacePath);
275
+ const stateDir = path.join(workspaceRoot, '.state');
263
276
  const archiveTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
264
277
  const archiveDir = path.join(stateDir, 'legacy-archive', archiveTimestamp);
265
278
 
@@ -288,7 +301,11 @@ function findLegacyTargets(workspacePath: string): CleanupTarget[] {
288
301
  if (fs.existsSync(sessionsDir)) {
289
302
  for (const file of fs.readdirSync(sessionsDir)) {
290
303
  if (!file.endsWith('.json')) continue;
291
- const filePath = path.join(sessionsDir, file);
304
+ const filePath = path.resolve(sessionsDir, file);
305
+ // CWE-22: verify the canonical path stays inside the canonical
306
+ // sessions dir before any filesystem access (readdir results are
307
+ // trusted, but defense-in-depth).
308
+ if (!isPathInside(sessionsDir, filePath)) continue;
292
309
  try {
293
310
  const content = fs.readFileSync(filePath, 'utf-8');
294
311
  const session = JSON.parse(content);
@@ -19,6 +19,7 @@
19
19
  import * as fs from 'fs';
20
20
  import * as path from 'path';
21
21
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
22
+ import { assertSafeDirectoryRoot, isPathInside } from '../utils/path-security.js';
22
23
 
23
24
  interface EvidenceOptions {
24
25
  workspace?: string;
@@ -40,11 +41,14 @@ interface TriggerDecisionEntry {
40
41
  }
41
42
 
42
43
  /**
43
- * Get the memory/logs directory for a workspace.
44
+ * Get the memory/logs directory for a workspace (canonical root).
44
45
  * SystemLogger writes to <workspace>/memory/logs/SYSTEM_YYYY-MM-DD.log.
46
+ * The workspace root is canonicalized once here so downstream containment
47
+ * checks compare canonical paths (relative --workspace inputs work).
45
48
  */
46
49
  function getLogDir(workspaceDir: string): string {
47
- return path.join(workspaceDir, 'memory', 'logs');
50
+ const root = assertSafeDirectoryRoot(workspaceDir, 'workspace path');
51
+ return path.join(root, 'memory', 'logs');
48
52
  }
49
53
 
50
54
  /**
@@ -108,7 +112,11 @@ function readRecentDecisions(logDir: string, limit: number): TriggerDecisionEntr
108
112
  for (const logFile of logFiles) {
109
113
  if (allEntries.length >= limit) break;
110
114
 
111
- const filePath = path.join(logDir, logFile);
115
+ // CWE-22: verify the canonical log path stays inside the canonical
116
+ // logDir before reading (canonical-vs-canonical containment, so relative
117
+ // workspace roots work).
118
+ const filePath = path.resolve(logDir, logFile);
119
+ if (!isPathInside(logDir, filePath)) continue;
112
120
  try {
113
121
  const content = fs.readFileSync(filePath, 'utf8');
114
122
  const entries = parseTriggerDecisions(content);
@@ -31,6 +31,7 @@
31
31
  import * as path from 'node:path';
32
32
  import * as fs from 'node:fs';
33
33
  import type { Command } from 'commander';
34
+ import { isPathInside } from '../utils/path-security.js';
34
35
  import {
35
36
  RULECODE_SPEC_TEXT,
36
37
  checkForbiddenPatterns,
@@ -113,11 +114,33 @@ function isGoldenTraceCaseInput(value: unknown): value is GoldenTraceCaseInput {
113
114
  /**
114
115
  * Load and validate golden trace cases from a JSON file.
115
116
  * Returns either the validated cases or a structured error.
117
+ * Exported for boundary regression tests (internal test surface).
116
118
  */
117
- function loadGoldenTraceCases(filePath: string): { cases?: GoldenTraceCaseInput[]; error?: { reason: string; nextAction: string } } {
119
+ export function loadGoldenTraceCases(
120
+ filePath: string,
121
+ workspaceDir?: string,
122
+ ): { cases?: GoldenTraceCaseInput[]; error?: { reason: string; nextAction: string } } {
118
123
  let raw: string;
119
124
  try {
125
+ if (!filePath || filePath.trim().length === 0) {
126
+ throw new Error('golden trace path is empty');
127
+ }
120
128
  const resolved = path.resolve(filePath);
129
+ // CWE-22 boundary: canonical containment against the workspace root
130
+ // (rejects sibling-prefix and traversal escapes; relative workspaces
131
+ // canonicalize consistently). Filesystem-root targets are rejected by
132
+ // containment when a workspace root is supplied.
133
+ const normalized = path.normalize(resolved);
134
+ if (normalized.split(/[\\/]/).includes('..')) {
135
+ throw new Error('golden trace path contains parent traversal');
136
+ }
137
+ if (workspaceDir) {
138
+ if (!isPathInside(workspaceDir, resolved)) {
139
+ throw new Error('golden trace path must be inside the workspace directory');
140
+ }
141
+ } else if (normalized === path.parse(normalized).root) {
142
+ throw new Error('golden trace path resolves to filesystem root');
143
+ }
121
144
  raw = fs.readFileSync(resolved, 'utf8');
122
145
  } catch (err) {
123
146
  const reason = err instanceof Error ? err.message : String(err);
@@ -286,7 +309,7 @@ export async function handleRulecodeReplay(opts: ReplayOptions): Promise<void> {
286
309
  return;
287
310
  }
288
311
 
289
- const traceResult = loadGoldenTraceCases(opts.goldenTrace);
312
+ const traceResult = loadGoldenTraceCases(opts.goldenTrace, opts.workspace);
290
313
  if (traceResult.error || traceResult.cases === undefined) {
291
314
  const { error } = traceResult;
292
315
  const output: RulecodeReplayOutput = {
@@ -45,29 +45,38 @@ interface ActivationDispatchOptions {
45
45
  }
46
46
 
47
47
  function mapRolloutDecision(reviewDecision: string | undefined): RolloutActivationDecision {
48
+ // P0-E (MVP_CORE_LOOP_CONTRACT INV-04): needs_revision 绝不映射 require_approval。
49
+ // 旧映射让"需修改"的评审结论伪装成正常审批进入 approval 队列(审计 ISSUE-027)。
50
+ // needs_revision 的出边是 revision loop(自动 reopen 修订目标),由
51
+ // rollout-reviewer-runner.handleRevisionRouting / auto-consumer 承担;
52
+ // CLI dispatch 对 needs_revision artifact 一律 refuse(structured reason + nextAction)。
48
53
  if (!reviewDecision) return 'require_approval';
49
54
  if (reviewDecision === 'approve_rollout') return 'auto_activate';
50
- if (reviewDecision === 'needs_revision') return 'require_approval';
51
55
  if (reviewDecision === 'reject') return 'reject';
52
56
  return 'require_approval';
53
57
  }
54
58
 
55
- function extractRolloutDecisionFromArtifact(artifact: PIArtifactRecord): RolloutActivationDecision {
59
+ /** 提取 artifact 上的原始 review decision(未映射),用于 needs_revision 拒绝分支 */
60
+ function extractRawRolloutReviewDecision(artifact: PIArtifactRecord): string | null {
56
61
  try {
57
62
  const parsed = JSON.parse(artifact.contentJson) as Record<string, unknown>;
58
63
  if (parsed && typeof parsed === 'object') {
59
64
  const review = parsed.review as Record<string, unknown> | undefined;
60
65
  if (review && typeof review.decision === 'string') {
61
- return mapRolloutDecision(review.decision);
66
+ return review.decision;
62
67
  }
63
68
  if (typeof parsed.rolloutDecision === 'string') {
64
- return mapRolloutDecision(parsed.rolloutDecision);
69
+ return parsed.rolloutDecision;
65
70
  }
66
71
  }
67
72
  } catch {
68
- return 'require_approval';
73
+ return null;
69
74
  }
70
- return 'require_approval';
75
+ return null;
76
+ }
77
+
78
+ function extractRolloutDecisionFromArtifact(artifact: PIArtifactRecord): RolloutActivationDecision {
79
+ return mapRolloutDecision(extractRawRolloutReviewDecision(artifact) ?? undefined);
71
80
  }
72
81
 
73
82
  function toSnapshot(record: PIArtifactRecord): PIArtifactSnapshot {
@@ -158,6 +167,29 @@ export async function handleRuntimeActivationDispatch(opts: ActivationDispatchOp
158
167
  }
159
168
 
160
169
  const artifactSnapshot = toSnapshot(artifactRecord);
170
+
171
+ // P0-E: needs_revision artifact 不允许手动 dispatch 入 approval (INV-04)。
172
+ // 出边是 revision loop: auto-consumer 会自动 reopen 修订目标;
173
+ // 手动场景给出结构化 next action (cli-6)。
174
+ const rawReviewDecision = extractRawRolloutReviewDecision(artifactRecord);
175
+ if (rawReviewDecision === 'needs_revision') {
176
+ const refused: ActivationDecision = {
177
+ decision: 'refused',
178
+ reason: 'rollout_needs_revision_not_dispatchable',
179
+ nextAction: 'Revision is handled by the automatic revision loop (rollout_reviewer reopens scribe/artificer). Inspect: pd runtime internalization list --json; advance manually: pd runtime internalization run-once --runner rollout_reviewer',
180
+ channel,
181
+ riskLevel: channel === 'code_tool_hook' ? 'high' : channel === 'skill' ? 'medium' : 'low',
182
+ };
183
+ if (opts.json) {
184
+ console.log(JSON.stringify(refused, null, 2));
185
+ } else {
186
+ console.log(formatTextOutput(refused));
187
+ console.log(' nextAction: ' + refused.nextAction);
188
+ }
189
+ process.exitCode = 1;
190
+ return;
191
+ }
192
+
161
193
  const rolloutDecision = extractRolloutDecisionFromArtifact(artifactRecord);
162
194
 
163
195
  const artifactReadModel = {
@@ -99,6 +99,54 @@ function mapCommitDecisionToAction(
99
99
  reason: 'task_not_found: task disappeared between scan and commit',
100
100
  nextAction: 'Re-scan or investigate concurrent deletion',
101
101
  };
102
+ case 'blocked_by_revision':
103
+ return {
104
+ taskId: commitResult.sourceTaskId,
105
+ taskKind: '',
106
+ decision: 'skipped',
107
+ reason: `blocked_by_revision: ${commitResult.reason} (runnerDecision=${commitResult.runnerDecision}); revision loop owns the out-edge`,
108
+ nextAction: 'Inspect the seeded repair/revision task: pd runtime internalization list --json',
109
+ };
110
+ case 'blocked_by_rejection':
111
+ return {
112
+ taskId: commitResult.sourceTaskId,
113
+ taskKind: '',
114
+ decision: 'skipped',
115
+ reason: `blocked_by_rejection: ${commitResult.reason} (runnerDecision=${commitResult.runnerDecision}); no successor, no approval`,
116
+ nextAction: 'Terminal reject — inspect the reviewer output or re-run the stage if the verdict was spurious',
117
+ };
118
+ case 'revision_reopened':
119
+ return {
120
+ taskId: commitResult.sourceTaskId,
121
+ taskKind: '',
122
+ decision: 'successor_created',
123
+ successorKind: 'evaluator',
124
+ successorTaskId: commitResult.reopenedTaskId,
125
+ };
126
+ case 'successor_reopened':
127
+ return {
128
+ taskId: commitResult.sourceTaskId,
129
+ taskKind: '',
130
+ decision: 'successor_created',
131
+ successorKind: commitResult.successorKind,
132
+ successorTaskId: commitResult.reopenedTaskId,
133
+ };
134
+ case 'revision_reopen_noop':
135
+ return {
136
+ taskId: commitResult.sourceTaskId,
137
+ taskKind: '',
138
+ decision: 'skipped',
139
+ reason: `revision_reopen_noop: ${commitResult.reason}; transition already materialized (same revision cause)`,
140
+ nextAction: 'No action — idempotent replay',
141
+ };
142
+ case 'blocked_missing_verdict':
143
+ return {
144
+ taskId: commitResult.taskId,
145
+ taskKind: '',
146
+ decision: 'skipped',
147
+ reason: `blocked_missing_verdict: ${commitResult.reason}; durable runnerDecision and legacy runs verdict both absent (fail-closed)`,
148
+ nextAction: 'Re-run the stage runner (writes durable verdict) or reconcile via the pending-artifacts report',
149
+ };
102
150
  }
103
151
  }
104
152
 
@@ -0,0 +1,163 @@
1
+ /**
2
+ * pd runtime internalization retry — needs_human_review 的 Owner 出边
3
+ * (MVP_CORE_LOOP_CONTRACT INV-03: inspect / retry / revise / reject-archive)。
4
+ *
5
+ * 修复前 needs_human_review 是 display-only 单向终态 (审计 ISSUE-006)。
6
+ * 本命令把 needs_human_review 任务重新入队 (→ pending, attemptCount 重置),
7
+ * 由 auto-consumer / run-once 重新驱动。
8
+ *
9
+ * Owner retry = 显式人类 authority reset,与 crash retry 严格区分:
10
+ * crash / lease recovery / automatic retry 保留 completionIntent(入口门
11
+ * resume 原 verdict,零 LLM);Owner retry 必须同时清空 runnerDecision 与
12
+ * completionIntent,允许新一轮 LLM verdict 成为 authority——否则入口门会
13
+ * resume/finalize 旧 verdict,LLM 永不运行,Owner retry 实际失效。
14
+ *
15
+ * 落库形态: status/attemptCount 与清空后的 metadata 在同一次 updateTask
16
+ * (SQLite 单条 UPDATE) 中原子生效——两个独立写之间失败会留下
17
+ * "authority 已清但任务仍 needs_human_review" 的 partial Owner action。
18
+ * metadata 不可 hydrate 时 fail closed (metadata_invalid),不得只改 status。
19
+ *
20
+ * CLI gate: 默认 dry-run;--confirm 才落地 (cli-4);JSON 模式严格单对象 (cli-1);
21
+ * 失败路径不产生任何状态变更 (cli-5)。
22
+ */
23
+
24
+ import * as path from 'path';
25
+ import { RuntimeStateManager } from '@principles/core/runtime-v2';
26
+ import { hydratePITaskRecord, createPITaskDiagnosticJson, mergePITaskMetadata } from '@principles/core/runtime-v2';
27
+ import { resolveWorkspaceDir } from '../resolve-workspace.js';
28
+
29
+ export interface InternalizationRetryOptions {
30
+ workspace?: string;
31
+ taskId?: string;
32
+ confirm?: boolean;
33
+ json?: boolean;
34
+ }
35
+
36
+ export interface InternalizationRetryOutput {
37
+ status: 'requeued' | 'dry_run' | 'skipped' | 'failed';
38
+ taskId: string;
39
+ taskKind?: string;
40
+ previousStatus?: string;
41
+ reason?: string;
42
+ nextAction?: string;
43
+ }
44
+
45
+ function emit(out: InternalizationRetryOutput, json?: boolean): void {
46
+ if (json) {
47
+ console.log(JSON.stringify(out, null, 2));
48
+ return;
49
+ }
50
+ console.log(`Retry: ${out.status}${out.previousStatus ? ` (was ${out.previousStatus})` : ''}`);
51
+ if (out.reason) console.log(` reason: ${out.reason}`);
52
+ if (out.nextAction) console.log(` nextAction: ${out.nextAction}`);
53
+ }
54
+
55
+ export async function handleRuntimeInternalizationRetry(opts: InternalizationRetryOptions): Promise<void> {
56
+ if (!opts.taskId) {
57
+ const out: InternalizationRetryOutput = {
58
+ status: 'failed',
59
+ taskId: '',
60
+ reason: 'task_id_required',
61
+ nextAction: 'Pass --task <taskId> (find ids via: pd runtime internalization queue --json or pd errors list)',
62
+ };
63
+ emit(out, opts.json);
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+
68
+ const workspaceDir = opts.workspace ? path.resolve(opts.workspace) : resolveWorkspaceDir();
69
+ const stateManager = new RuntimeStateManager({ workspaceDir });
70
+ try {
71
+ await stateManager.initialize();
72
+ const task = await stateManager.getTask(opts.taskId);
73
+ if (!task) {
74
+ const out: InternalizationRetryOutput = {
75
+ status: 'failed',
76
+ taskId: opts.taskId,
77
+ reason: 'task_not_found',
78
+ nextAction: 'Verify the task id and workspace',
79
+ };
80
+ emit(out, opts.json);
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+
85
+ if (task.status !== 'needs_human_review') {
86
+ const out: InternalizationRetryOutput = {
87
+ status: 'skipped',
88
+ taskId: opts.taskId,
89
+ taskKind: task.taskKind,
90
+ previousStatus: task.status,
91
+ reason: 'only_needs_human_review_tasks_are_retryable',
92
+ nextAction: 'This task is not in the owner attention queue; use run-once / enqueue-successors instead',
93
+ };
94
+ emit(out, opts.json);
95
+ return;
96
+ }
97
+
98
+ if (!opts.confirm) {
99
+ const out: InternalizationRetryOutput = {
100
+ status: 'dry_run',
101
+ taskId: opts.taskId,
102
+ taskKind: task.taskKind,
103
+ previousStatus: task.status,
104
+ reason: 'dry_run_no_mutation',
105
+ nextAction: 'Re-run with --confirm to requeue this task',
106
+ };
107
+ emit(out, opts.json);
108
+ return;
109
+ }
110
+
111
+ // Owner retry = authority reset: runnerDecision 与 completionIntent 同时
112
+ // 清空 (保留 revisionCount / revisionCauseId / rolloutRevisionPayload /
113
+ // repairPayload / lineage — revision budget 证据不动)。
114
+ const piTask = hydratePITaskRecord(task);
115
+ if (!piTask) {
116
+ // fail closed: 只改 status 会把(可能损坏的)旧 authority 记录原样留在
117
+ // metadata 里,下一次 run 由它接管 —— 产生 partial retry。
118
+ const out: InternalizationRetryOutput = {
119
+ status: 'failed',
120
+ taskId: opts.taskId,
121
+ taskKind: task.taskKind,
122
+ previousStatus: task.status,
123
+ reason: 'metadata_invalid',
124
+ nextAction: 'Task metadata failed PI hydration; a retry now would risk a partial authority reset. Inspect: pd runtime internalization integrity --json',
125
+ };
126
+ emit(out, opts.json);
127
+ process.exitCode = 1;
128
+ return;
129
+ }
130
+ // 原子单写: 同一 patch 同时落 status=pending / attemptCount=0 / 清空后的
131
+ // diagnosticJson。updateTask 抛错时 DB 行保持原样(单条 UPDATE),无 partial reset。
132
+ const merged = mergePITaskMetadata(piTask, {
133
+ runnerDecision: undefined,
134
+ completionIntent: undefined,
135
+ });
136
+ await stateManager.updateTask(opts.taskId, {
137
+ status: 'pending',
138
+ attemptCount: 0,
139
+ diagnosticJson: createPITaskDiagnosticJson(merged),
140
+ });
141
+
142
+ const out: InternalizationRetryOutput = {
143
+ status: 'requeued',
144
+ taskId: opts.taskId,
145
+ taskKind: task.taskKind,
146
+ previousStatus: task.status,
147
+ nextAction: 'Task requeued; it will be picked up by the auto-consumer cycle, or advance manually: pd runtime internalization run-once',
148
+ };
149
+ emit(out, opts.json);
150
+ } catch (err) {
151
+ const out: InternalizationRetryOutput = {
152
+ status: 'failed',
153
+ taskId: opts.taskId,
154
+ reason: err instanceof Error ? err.message : String(err),
155
+ nextAction: 'Check workspace DB integrity (pd runtime internalization integrity)',
156
+ };
157
+ emit(out, opts.json);
158
+ process.exitCode = 1;
159
+ } finally {
160
+ await stateManager.close();
161
+ }
162
+ }
163
+
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ import { handleRuntimeUat } from './commands/runtime-uat.js';
33
33
  import { handleRuntimeInternalizationQueue } from './commands/runtime-internalization-queue.js';
34
34
  import { handleRuntimeInternalizationWakeOnce } from './commands/runtime-internalization-wake-once.js';
35
35
  import { handleRuntimeInternalizationRunOnce } from './commands/runtime-internalization-run-once.js';
36
+ import { handleRuntimeInternalizationRetry } from './commands/runtime-internalization-retry.js';
36
37
  import { registerRunRuleHostCommand } from './commands/runtime-internalization-run-rulehost.js';
37
38
  import { handleCandidateList, handleCandidateShow, handleCandidateIntake, handleCandidateAudit, handleCandidateRepair, handleCandidateRoute, handleCandidateInternalize, handleCandidateInternalizationBackfill } from './commands/candidate.js';
38
39
  import { handleArtifactShow } from './commands/artifact.js';
@@ -554,6 +555,17 @@ internalizationCmd
554
555
  await handleRuntimeInternalizationWakeOnce({ workspace: opts.workspace, dryRun: opts.dryRun, json: opts.json });
555
556
  });
556
557
 
558
+ internalizationCmd
559
+ .command('retry')
560
+ .description('Requeue a needs_human_review task (owner attention queue out-edge)')
561
+ .option('-w, --workspace <path>', 'Workspace directory')
562
+ .requiredOption('--task <taskId>', 'Task id to requeue')
563
+ .option('--confirm', 'Actually requeue (default is dry-run)')
564
+ .option('--json', 'Output as JSON')
565
+ .action(async (opts) => {
566
+ await handleRuntimeInternalizationRetry({ workspace: opts.workspace, taskId: opts.task, confirm: opts.confirm, json: opts.json });
567
+ });
568
+
557
569
  internalizationCmd
558
570
  .command('run-once')
559
571
  .description('Wake-and-run: lease the next PI task and execute it')