Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/pm-dispatch/references/state-machine.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
`needs-user-decision` 互斥;成因恒是半写的转换,故**一笔 replace**、⛔ 不 add。
- **`pm:queue` 腐化视界 3 天**:队列是唯一「等待不是一种状态」的态,越界即欠**一次显
式转换**(派发 / 转 `needs-user-decision` / 停放 / 撤单 / 改写前提),⛔ 不是排期。
- **生成物不分叉**:`skills/**` 生成器产物跑 `check-governed-merges.mjs --test`,⛔ 不按路径手判。
- **`needs:contract-review` 双载体**:卡与交付 PR 两边都挂好、挂与清各一笔、两向都读回(被剥不
是红灯是放行);卡侧先挂而 PR 尚不存在是合法中间态,不是半写;**交接即标签** —— 只写
交接评论而不同笔挂标 = 空交接,收件箱只认标签。
Expand Down
47 changes: 45 additions & 2 deletions packages/spec/scripts/lib/generated-output.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,24 @@
* output folder before rewriting will delete such a file on the next real run,
* so the check must fail on it too. `git diff --exit-code` cannot see this
* class at all — a stale *untracked* leftover is invisible to it.
*
* ## `--generated-manifest=<file>` — the output set, declared by the generator
*
* The governed-merge register (`scripts/pm/check-governed-merges.mjs`) carves
* generator-owned files inside `skills/**` out of the human-merge fork
* (maintainer ruling 2026-08-25, #11705). That carve-out is granted per FILE
* and only on proof, and the proof has two halves: the bytes must match (the
* `--check` run above answers that), and the path must be one this generator
* actually writes. The second half cannot be answered from the checker's side
* without hand-copying a path list — exactly what the ruling forbids — so the
* generator answers it: with this flag, `flush()` writes the repo-relative
* paths of everything it emitted, as JSON, to the given file.
*
* Nothing about generation changes: the manifest is written from the same
* `emitted` map the write and `--check` dispositions read, so it cannot name a
* file a real run would not produce, and it is written BEFORE any drift exit so
* a failing check still reports what it was checking. The flag only ever writes
* to the path the caller names — never into the repo.
*/

import fs from 'fs';
Expand All@@ -28,6 +46,15 @@ import path from 'path';
/** Decides which on-disk paths a managed dir's regeneration owns. */
export type Owns = (absPath: string) => boolean;

/** The flag that asks `flush()` to declare its output set. */
export const MANIFEST_FLAG = '--generated-manifest=';

/** Where to write the output manifest, or null when the flag is absent. */
export function manifestPathFromArgv(argv: string[] = process.argv): string | null {
const flag = argv.find((a) => a.startsWith(MANIFEST_FLAG));
return flag ? flag.slice(MANIFEST_FLAG.length) : null;
}

export interface FlushOptions {
/** Human name of the generated surface, for drift messages. */
surface: string;
Expand All@@ -42,8 +69,9 @@ export interface FlushOptions {
guard?: () => string | null;
}

export function createSink(options: { check: boolean; repoRoot: string }) {
export function createSink(options: { check: boolean; repoRoot: string; manifestPath?: string | null }) {
const { check, repoRoot } = options;
const manifestPath = options.manifestPath !== undefined ? options.manifestPath : manifestPathFromArgv();

/** Absolute path → intended content. */
const emitted = new Map<string, string>();
Expand DownExpand Up@@ -102,7 +130,22 @@ export function createSink(options: { check: boolean; repoRoot: string }) {
}
}

/** Repo-relative, POSIX-spelled, sorted — the paths a real run writes. */
function declaredOutputs(): string[] {
return [...emitted.keys()].map((f) => rel(f).split(path.sep).join('/')).sort();
}

function flush(opts: FlushOptions): void {
// Before the guard and before any drift exit: a check that is about to fail
// must still be able to say WHAT it was checking, and the caller reads this
// file alongside the exit code rather than instead of it.
if (manifestPath) {
fs.writeFileSync(
manifestPath,
JSON.stringify({ surface: opts.surface, mode: check ? 'check' : 'write', outputs: declaredOutputs() }, null, 2) + '\n',
);
}

const failure = opts.guard?.();
if (failure) {
console.error(`\n✗ ${failure}\n`);
Expand DownExpand Up@@ -150,5 +193,5 @@ export function createSink(options: { check: boolean; repoRoot: string }) {
process.exit(1);
}

return { emit, manageDir, wasEmitted, flush };
return { emit, manageDir, wasEmitted, declaredOutputs, flush };
}
Loading
Loading