@powerhousedao/ph-clint-cli 0.1.0-dev.20 → 0.1.0-dev.22

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/dist/cli.js CHANGED
@@ -6,8 +6,6 @@
6
6
  * rewritten by ph-clint-cli's generator when the project spec changes.
7
7
  * Everything outside the markers is user-editable and preserved.
8
8
  */
9
- import path from 'node:path';
10
- import { existsSync } from 'node:fs';
11
9
  import { defineCli, buildDefaultReactor } from '@powerhousedao/ph-clint';
12
10
  import { CLI_NAME, CLI_VERSION, CLI_ROOT } from './config.js';
13
11
  import { configSchema, secretsSchema } from './framework.js';
@@ -23,15 +21,10 @@ import { skillsSync } from './commands/clint-skills-sync.js';
23
21
  import { specChangeTrigger } from './triggers/spec-change.js';
24
22
  import { publishTrigger } from './triggers/publish-trigger.js';
25
23
  // @clint:end imports
26
- // Connect (ph connect) must run inside the Reactor Package (ph-clint-app).
27
- const appDir = path.resolve(CLI_ROOT, '../ph-clint-app');
28
- function resolveConnectAssets(dir) {
29
- const assetsDir = path.join(dir, 'dist', 'connect');
30
- return existsSync(path.join(assetsDir, 'index.html')) ? assetsDir : undefined;
31
- }
32
24
  export const cli = defineCli({
33
25
  name: CLI_NAME,
34
26
  version: CLI_VERSION,
27
+ root: CLI_ROOT,
35
28
  description: `${CLI_NAME} v${CLI_VERSION} — scaffold and maintain ph-clint implementation projects`,
36
29
  configSchema,
37
30
  secretsSchema,
@@ -75,11 +68,13 @@ export const cli = defineCli({
75
68
  cli.configureReactor({
76
69
  create: (ctx) => buildDefaultReactor(ctx, {
77
70
  documentModels,
78
- drive: { name: 'Clint' },
71
+ drives: [
72
+ { name: 'Clint Folders', role: 'personal' },
73
+ ],
79
74
  subscriptions: { documentTypes: ['powerhouse/ph-clint-project'] },
80
75
  }),
81
76
  switchboard: { enabled: true },
82
- connect: { enabled: true, workdir: appDir, assetsDir: resolveConnectAssets(appDir) },
77
+ connect: { enabled: true },
83
78
  });
84
79
  // @clint:end reactor
85
80
  // @clint:begin mastra
@@ -91,7 +91,7 @@ export function buildCliPackageJson(spec) {
91
91
  if (spec.description)
92
92
  pkg.description = spec.description;
93
93
  pkg.bin = { [bin]: './dist/main.js' };
94
- pkg.files = ['dist', 'prompts'];
94
+ pkg.files = ['dist'];
95
95
  pkg.scripts = scripts;
96
96
  pkg.dependencies = dependencies;
97
97
  pkg.devDependencies = {
@@ -55,6 +55,8 @@ export declare const init: import("@powerhousedao/ph-clint").Command<z.ZodObject
55
55
  }[];
56
56
  scope?: string | undefined;
57
57
  bin?: string | undefined;
58
+ documentId?: string | undefined;
59
+ documentType?: string | undefined;
58
60
  };
59
61
  fileCount: number;
60
62
  };
@@ -13,11 +13,14 @@
13
13
  */
14
14
  import path from 'node:path';
15
15
  import { defineCommand } from '@powerhousedao/ph-clint';
16
+ import { importSpec } from '@powerhousedao/ph-clint-app/document-models/ph-clint-project';
16
17
  import { z } from 'zod';
17
18
  import { clintProjectSpecSchema, } from '../spec/types.js';
18
19
  import { generateProject } from '../codegen/index.js';
19
20
  import { isDirEmptyEnough } from '../codegen/write.js';
20
21
  import { runPhInit, runPnpmInstall } from '../codegen/scaffold.js';
22
+ import { writeProjectSpec } from '../spec/file.js';
23
+ import { specToImportInput } from '../triggers/spec-change.js';
21
24
  const inputSchema = z.object({
22
25
  dir: z
23
26
  .string()
@@ -90,7 +93,7 @@ export const init = defineCommand({
90
93
  promptForDefaults: true,
91
94
  promptOptional: ['description'],
92
95
  },
93
- execute: async (input, { workdir, stdout }) => {
96
+ execute: async (input, { workdir, stdout, reactor: getReactor, folders }) => {
94
97
  const { name: bareName } = splitPackageName(input.name);
95
98
  const targetDir = path.resolve(workdir, input.dir ?? bareName);
96
99
  if (!input.force) {
@@ -110,6 +113,27 @@ export const init = defineCommand({
110
113
  allowNonEmpty: input.force,
111
114
  });
112
115
  stdout(`Generated ${result.files.length} files in ${targetDir}\n`);
116
+ // Create spec document in personal drive (if reactor available)
117
+ if (getReactor && folders) {
118
+ try {
119
+ const reactor = await getReactor();
120
+ if (reactor) {
121
+ const newDoc = await reactor.client.createEmpty('powerhouse/ph-clint-project');
122
+ const docId = newDoc.header.id;
123
+ await folders.addDocument(docId, `specs/${spec.name}`, spec.name);
124
+ const importInput = specToImportInput(spec);
125
+ await reactor.client.execute(docId, 'main', [importSpec(importInput)]);
126
+ spec.documentId = docId;
127
+ spec.documentType = 'powerhouse/ph-clint-project';
128
+ await writeProjectSpec(targetDir, spec);
129
+ stdout(`Created spec document ${docId} in Clint Folders/specs/${spec.name}\n`);
130
+ }
131
+ }
132
+ catch (err) {
133
+ // Non-fatal — trigger will pick it up on next startup
134
+ stdout(`Note: could not create spec document — ${err instanceof Error ? err.message : String(err)}\n`);
135
+ }
136
+ }
113
137
  const split = spec.features.powerhouse !== 'Disabled';
114
138
  // Reactor package scaffolding (split layout only).
115
139
  if (split && result.appDir && !input.skipPhInit) {
@@ -14,7 +14,6 @@ export declare const configSchema: z.ZodObject<{
14
14
  model: z.ZodDefault<z.ZodString>;
15
15
  devServicePort: z.ZodDefault<z.ZodNumber>;
16
16
  phVersion: z.ZodDefault<z.ZodString>;
17
- projectDocumentId: z.ZodOptional<z.ZodString>;
18
17
  }, z.core.$strip>;
19
18
  export declare const secretsSchema: z.ZodObject<{
20
19
  apiKey: z.ZodOptional<z.ZodString>;
@@ -30,38 +29,32 @@ export declare const defineCommand: <TInput extends z.ZodType, TOutput = unknown
30
29
  model: string;
31
30
  devServicePort: number;
32
31
  phVersion: string;
33
- projectDocumentId?: string | undefined;
34
32
  apiKey?: string | undefined;
35
33
  }, import("@powerhousedao/ph-clint").InferRegistry<readonly [import("document-model").DocumentModelModule<import("@powerhousedao/ph-clint-app").PhClintProjectPHState>]>>) => Promise<TOutput>;
36
34
  }) => import("@powerhousedao/ph-clint").Command<TInput, TOutput, {
37
35
  model: string;
38
36
  devServicePort: number;
39
37
  phVersion: string;
40
- projectDocumentId?: string | undefined;
41
38
  apiKey?: string | undefined;
42
39
  }>, defineTrigger: <TState = Record<string, unknown>>(options: import("@powerhousedao/ph-clint").TriggerOptions<TState, {
43
40
  model: string;
44
41
  devServicePort: number;
45
42
  phVersion: string;
46
- projectDocumentId?: string | undefined;
47
43
  apiKey?: string | undefined;
48
44
  }, import("@powerhousedao/ph-clint").InferRegistry<readonly [import("document-model").DocumentModelModule<import("@powerhousedao/ph-clint-app").PhClintProjectPHState>]>>) => import("@powerhousedao/ph-clint").Trigger<TState, {
49
45
  model: string;
50
46
  devServicePort: number;
51
47
  phVersion: string;
52
- projectDocumentId?: string | undefined;
53
48
  apiKey?: string | undefined;
54
49
  }, import("@powerhousedao/ph-clint").InferRegistry<readonly [import("document-model").DocumentModelModule<import("@powerhousedao/ph-clint-app").PhClintProjectPHState>]>>, defineService: (options: import("@powerhousedao/ph-clint").ServiceDefinition<{
55
50
  model: string;
56
51
  devServicePort: number;
57
52
  phVersion: string;
58
- projectDocumentId?: string | undefined;
59
53
  apiKey?: string | undefined;
60
54
  }>) => import("@powerhousedao/ph-clint").ServiceDefinition<{
61
55
  model: string;
62
56
  devServicePort: number;
63
57
  phVersion: string;
64
- projectDocumentId?: string | undefined;
65
58
  apiKey?: string | undefined;
66
59
  }>, createDocumentChangeTrigger: <T extends string, TState extends {
67
60
  pending: number;
@@ -71,13 +64,11 @@ export declare const defineCommand: <TInput extends z.ZodType, TOutput = unknown
71
64
  model: string;
72
65
  devServicePort: number;
73
66
  phVersion: string;
74
- projectDocumentId?: string | undefined;
75
67
  apiKey?: string | undefined;
76
68
  }, TState>) => import("@powerhousedao/ph-clint").Trigger<TState, {
77
69
  model: string;
78
70
  devServicePort: number;
79
71
  phVersion: string;
80
- projectDocumentId?: string | undefined;
81
72
  apiKey?: string | undefined;
82
73
  }, import("@powerhousedao/ph-clint").InferRegistry<readonly [import("document-model").DocumentModelModule<import("@powerhousedao/ph-clint-app").PhClintProjectPHState>]>>;
83
74
  export { registry } from './framework.gen.js';
package/dist/framework.js CHANGED
@@ -25,10 +25,6 @@ export const configSchema = z.object({
25
25
  .string()
26
26
  .default('6.0.0-dev.170')
27
27
  .describe('Pinned Powerhouse version used when emitting a new impl project'),
28
- projectDocumentId: z
29
- .string()
30
- .optional()
31
- .describe('ID of the powerhouse/ph-clint-project document that drives codegen (set on first regen; edit to switch projects)'),
32
28
  });
33
29
  export const secretsSchema = z.object({
34
30
  apiKey: z.string().optional().describe('Anthropic API key'),
@@ -31,6 +31,11 @@ export const clintProject = defineService({
31
31
  ],
32
32
  projectScanner: {
33
33
  isProjectFolder: (p) => {
34
+ // Match if a project-spec.json exists (split-layout root)
35
+ const specPath = path.join(p, '.ph', 'ph-clint-cli', 'project-spec.json');
36
+ if (fs.existsSync(specPath))
37
+ return true;
38
+ // Match if package.json has ph-clint as a dependency (flat layout or cli subfolder)
34
39
  const pkg = path.join(p, 'package.json');
35
40
  if (!fs.existsSync(pkg))
36
41
  return false;
@@ -42,6 +47,22 @@ export const clintProject = defineService({
42
47
  return false;
43
48
  }
44
49
  },
50
+ getDocumentLink: (p) => {
51
+ const specPath = path.join(p, '.ph', 'ph-clint-cli', 'project-spec.json');
52
+ if (!fs.existsSync(specPath))
53
+ return undefined;
54
+ try {
55
+ const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
56
+ if (spec.documentId) {
57
+ return {
58
+ documentId: spec.documentId,
59
+ documentType: spec.documentType ?? 'powerhouse/ph-clint-project',
60
+ };
61
+ }
62
+ }
63
+ catch { /* noop */ }
64
+ return undefined;
65
+ },
45
66
  },
46
67
  restart: { enabled: false, maxRetries: 0, delay: 0 },
47
68
  });
@@ -15,4 +15,7 @@ import { type ClintProjectSpec } from './types.js';
15
15
  * Returns `null` if the document has no valid package name yet — callers
16
16
  * should wait for the user to populate it before triggering codegen.
17
17
  */
18
- export declare function specFromDocumentState(state: PhClintProjectGlobalState): ClintProjectSpec | null;
18
+ export declare function specFromDocumentState(state: PhClintProjectGlobalState, meta?: {
19
+ documentId?: string;
20
+ documentType?: string;
21
+ }): ClintProjectSpec | null;
@@ -5,7 +5,7 @@ import { clintProjectSpecSchema, } from './types.js';
5
5
  * Returns `null` if the document has no valid package name yet — callers
6
6
  * should wait for the user to populate it before triggering codegen.
7
7
  */
8
- export function specFromDocumentState(state) {
8
+ export function specFromDocumentState(state, meta) {
9
9
  if (!state.name)
10
10
  return null;
11
11
  const parsed = clintProjectSpecSchema.safeParse({
@@ -29,6 +29,8 @@ export function specFromDocumentState(state) {
29
29
  name: s.name,
30
30
  githubUrl: s.githubUrl,
31
31
  })),
32
+ documentId: meta?.documentId,
33
+ documentType: meta?.documentType,
32
34
  });
33
35
  return parsed.success ? parsed.data : null;
34
36
  }
@@ -65,6 +65,8 @@ export declare const clintProjectSpecSchema: z.ZodObject<{
65
65
  name: z.ZodString;
66
66
  githubUrl: z.ZodString;
67
67
  }, z.core.$strip>>>;
68
+ documentId: z.ZodOptional<z.ZodString>;
69
+ documentType: z.ZodOptional<z.ZodString>;
68
70
  }, z.core.$strip>;
69
71
  export type ClintProjectSpec = z.infer<typeof clintProjectSpecSchema>;
70
72
  export type ClintProjectSpecInput = z.input<typeof clintProjectSpecSchema>;
@@ -79,6 +79,10 @@ export const clintProjectSpecSchema = z.object({
79
79
  * (kebab-case) and a GitHub URL for installation.
80
80
  */
81
81
  externalSkills: z.array(externalSkillSchema).default([]),
82
+ /** ID of the source specification document (if backed by a Powerhouse document). */
83
+ documentId: z.string().optional(),
84
+ /** Document type of the source specification document. */
85
+ documentType: z.string().optional(),
82
86
  });
83
87
  /** npm package name (e.g. `@scope/foo` or `foo`). */
84
88
  export function getPackageName(spec) {
@@ -6,6 +6,5 @@ export declare const publishTrigger: import("@powerhousedao/ph-clint").Trigger<{
6
6
  model: string;
7
7
  devServicePort: number;
8
8
  phVersion: string;
9
- projectDocumentId?: string | undefined;
10
9
  apiKey?: string | undefined;
11
10
  }, import("@powerhousedao/ph-clint").InferRegistry<readonly [import("document-model").DocumentModelModule<import("@powerhousedao/ph-clint-app").PhClintProjectPHState>]>>;
@@ -1,11 +1,15 @@
1
1
  /**
2
- * `publish-trigger` — watches `powerhouse/ph-clint-project` documents for
2
+ * `publish-trigger` — watches ALL `powerhouse/ph-clint-project` documents for
3
3
  * publish records with `Pending` status and invokes the publish pipeline.
4
4
  *
5
5
  * Unlike `spec-change`, this trigger does NOT convert to a `ClintProjectSpec`.
6
6
  * It reads `publishHistory` directly from the document state, since publish
7
7
  * records are a doc-model concern (not a codegen input).
8
8
  *
9
+ * Uses the multi-doc path (no `documentId`), scanning all matching documents
10
+ * on each poll. For each document with a pending publish record, it uses the
11
+ * project mapping to resolve the on-disk project directory.
12
+ *
9
13
  * Flow:
10
14
  * 1. On document change, scans `publishHistory` for records with status
11
15
  * `"Pending"`.
@@ -15,26 +19,14 @@
15
19
  * c. Dispatches SET_PUBLISH_STATUS({ id, status: "Succeeded"|"Failed" }).
16
20
  */
17
21
  import { spawn } from 'node:child_process';
22
+ import path from 'node:path';
23
+ import { scanProjects, getProjectMapping } from '@powerhousedao/ph-clint';
18
24
  import { setPublishStatus } from '@powerhousedao/ph-clint-app';
19
25
  import { createDocumentChangeTrigger } from '../framework.js';
26
+ import { specFromDocumentState } from '../spec/from-document.js';
27
+ import { clintProject } from '../services/clint-project.js';
20
28
  const DOCUMENT_TYPE = 'powerhouse/ph-clint-project';
21
29
  const TAG = '[publish]';
22
- /** Find the first ph-clint-project document on the default drive. */
23
- async function findProjectDocumentId(client, driveId) {
24
- try {
25
- const children = await client.getChildren(driveId);
26
- const results = (children?.results ?? []);
27
- for (const node of results) {
28
- if (node.header?.documentType === DOCUMENT_TYPE && node.header.id) {
29
- return node.header.id;
30
- }
31
- }
32
- }
33
- catch {
34
- // fall through
35
- }
36
- return undefined;
37
- }
38
30
  /** Run a shell command and return stdout. Rejects on non-zero exit. */
39
31
  function exec(cmd, args, cwd, onLog) {
40
32
  return new Promise((resolve, reject) => {
@@ -82,91 +74,96 @@ export function tagToCli(tag) {
82
74
  export const publishTrigger = createDocumentChangeTrigger({
83
75
  id: 'publish-trigger',
84
76
  documentType: DOCUMENT_TYPE,
77
+ // No documentId → watches ALL ph-clint-project docs
85
78
  // Don't fire on startup — only react to new pending records.
86
79
  initialReconcile: false,
87
- // Resolve the target document lazily (same pattern as spec-change trigger).
88
- documentId: async (ctx) => {
89
- const configured = ctx.context.config.projectDocumentId &&
90
- ctx.context.config.projectDocumentId.length > 0
91
- ? ctx.context.config.projectDocumentId
92
- : undefined;
93
- if (configured)
94
- return configured;
95
- const reactor = await ctx.reactor();
96
- if (!reactor)
97
- return undefined;
98
- return findProjectDocumentId(reactor.client, reactor.driveId);
99
- },
100
- async onChange(doc, ctx) {
80
+ async onChange(docs, ctx) {
101
81
  const log = ctx.context.log;
102
- const publishHistory = doc.state.global.publishHistory ?? [];
103
- const pending = publishHistory.filter((r) => r.status === 'Pending');
104
- if (pending.length === 0)
105
- return null;
106
- // Process the first pending record (one per tick to avoid races).
107
- const record = pending[0];
108
- const cliTag = tagToCli(record.tag);
109
- if (!cliTag) {
110
- log?.warn(`${TAG} unknown publish tag "${record.tag}" for record ${record.id}`);
111
- return null;
112
- }
113
- log?.info(`${TAG} found pending ${record.tag} publish (id=${record.id}, version=${record.version})`);
114
- return {
115
- type: 'function',
116
- params: {
117
- fn: async () => {
118
- const reactor = await ctx.reactor();
119
- if (!reactor) {
120
- log?.error(`${TAG} reactor not available, cannot update publish status`);
121
- return;
122
- }
123
- // Mark as InProgress.
124
- log?.info(`${TAG} marking record ${record.id} as InProgress`);
125
- await reactor.client.execute(doc.header.id, 'main', [
126
- setPublishStatus({ id: record.id, status: 'InProgress' }),
127
- ]);
128
- // Install dependencies in the generated project before publishing.
129
- const workdir = ctx.context.workdir;
130
- log?.info(`${TAG} running pnpm install in ${workdir}`);
131
- try {
132
- await exec('pnpm', ['install'], workdir, (msg) => log?.debug(`${TAG} [install] ${msg}`));
133
- }
134
- catch (installErr) {
135
- log?.error(`${TAG} pnpm install failed: ${installErr instanceof Error ? installErr.message : String(installErr)}`);
136
- }
137
- // Run the publish command.
138
- let success = false;
139
- try {
140
- log?.info(`${TAG} invoking clint-project-publish --tag ${cliTag}`);
141
- const { clintProjectPublish } = await import('../commands/clint-project-publish.js');
142
- const result = await clintProjectPublish.execute({ tag: cliTag, dryRun: false, skipBuild: false, skipGitCheck: true, verbose: false },
143
- // @ts-expect-error — minimal context; publish uses workdir + stdout.
144
- {
145
- workdir: ctx.context.workdir,
146
- stdout: (msg) => log?.info(`${TAG} ${msg}`),
147
- log,
148
- });
149
- success = !result.text.includes('failed');
150
- }
151
- catch (err) {
152
- log?.error(`${TAG} publish failed: ${err instanceof Error ? err.message : String(err)}`);
153
- }
154
- // Update status.
155
- const finalStatus = success ? 'Succeeded' : 'Failed';
156
- log?.info(`${TAG} marking record ${record.id} as ${finalStatus}`);
157
- await reactor.client.execute(doc.header.id, 'main', [
158
- setPublishStatus({
159
- id: record.id,
160
- status: finalStatus,
161
- }),
162
- ]);
82
+ const folders = ctx.context.folders;
83
+ // Build the mapping to resolve on-disk project directories
84
+ const scanner = clintProject.projectScanner;
85
+ const scanResults = scanner
86
+ ? scanProjects(ctx.context.workdir, scanner)
87
+ : [];
88
+ const mapping = await getProjectMapping(scanResults, folders);
89
+ for (const doc of docs) {
90
+ const publishHistory = doc.state.global.publishHistory ?? [];
91
+ const pending = publishHistory.filter((r) => r.status === 'Pending');
92
+ if (pending.length === 0)
93
+ continue;
94
+ // Resolve the on-disk target directory for this document
95
+ const spec = specFromDocumentState(doc.state.global, {
96
+ documentId: doc.header.id,
97
+ documentType: doc.header.documentType,
98
+ });
99
+ const entry = mapping.find((m) => m.documentId === doc.header.id);
100
+ const workdir = entry?.path
101
+ ?? (spec ? path.join(ctx.context.workdir, spec.name) : ctx.context.workdir);
102
+ // Process the first pending record (one per tick to avoid races).
103
+ const record = pending[0];
104
+ const cliTag = tagToCli(record.tag);
105
+ if (!cliTag) {
106
+ log?.warn(`${TAG} unknown publish tag "${record.tag}" for record ${record.id}`);
107
+ continue;
108
+ }
109
+ log?.info(`${TAG} found pending ${record.tag} publish for doc ${doc.header.id} (id=${record.id}, version=${record.version})`);
110
+ return {
111
+ type: 'function',
112
+ params: {
113
+ fn: async () => {
114
+ const reactor = await ctx.reactor();
115
+ if (!reactor) {
116
+ log?.error(`${TAG} reactor not available, cannot update publish status`);
117
+ return;
118
+ }
119
+ // Mark as InProgress.
120
+ log?.info(`${TAG} marking record ${record.id} as InProgress`);
121
+ await reactor.client.execute(doc.header.id, 'main', [
122
+ setPublishStatus({ id: record.id, status: 'InProgress' }),
123
+ ]);
124
+ // Install dependencies in the generated project before publishing.
125
+ log?.info(`${TAG} running pnpm install in ${workdir}`);
126
+ try {
127
+ await exec('pnpm', ['install'], workdir, (msg) => log?.debug(`${TAG} [install] ${msg}`));
128
+ }
129
+ catch (installErr) {
130
+ log?.error(`${TAG} pnpm install failed: ${installErr instanceof Error ? installErr.message : String(installErr)}`);
131
+ }
132
+ // Run the publish command.
133
+ let success = false;
134
+ try {
135
+ log?.info(`${TAG} invoking clint-project-publish --tag ${cliTag}`);
136
+ const { clintProjectPublish } = await import('../commands/clint-project-publish.js');
137
+ const result = await clintProjectPublish.execute({ tag: cliTag, dryRun: false, skipBuild: false, skipGitCheck: true, verbose: false },
138
+ // @ts-expect-error — minimal context; publish uses workdir + stdout.
139
+ {
140
+ workdir,
141
+ stdout: (msg) => log?.info(`${TAG} ${msg}`),
142
+ log,
143
+ });
144
+ success = !result.text.includes('failed');
145
+ }
146
+ catch (err) {
147
+ log?.error(`${TAG} publish failed: ${err instanceof Error ? err.message : String(err)}`);
148
+ }
149
+ // Update status.
150
+ const finalStatus = success ? 'Succeeded' : 'Failed';
151
+ log?.info(`${TAG} marking record ${record.id} as ${finalStatus}`);
152
+ await reactor.client.execute(doc.header.id, 'main', [
153
+ setPublishStatus({
154
+ id: record.id,
155
+ status: finalStatus,
156
+ }),
157
+ ]);
158
+ },
163
159
  },
164
- },
165
- callbacks: {
166
- onFailure: (err) => {
167
- log?.error(`${TAG} publish trigger failed: ${err.message}`);
160
+ callbacks: {
161
+ onFailure: (err) => {
162
+ log?.error(`${TAG} publish trigger failed: ${err.message}`);
163
+ },
168
164
  },
169
- },
170
- };
165
+ };
166
+ }
167
+ return null;
171
168
  },
172
169
  });
@@ -1,12 +1,14 @@
1
+ import type { ImportSpecInput } from '@powerhousedao/ph-clint-app/document-models/ph-clint-project';
1
2
  import type { ClintProjectSpec } from '../spec/types.js';
2
- /** Stable hash of a ClintProjectSpec (sha256 of canonical-key JSON). */
3
+ /** Stable hash of a ClintProjectSpec (sha256 of canonical-key JSON).
4
+ * Excludes documentId/documentType — they don't affect codegen output. */
3
5
  export declare function hashSpec(spec: ClintProjectSpec): string;
6
+ export declare function specToImportInput(spec: ClintProjectSpec): ImportSpecInput;
4
7
  export declare const specChangeTrigger: import("@powerhousedao/ph-clint").Trigger<{
5
8
  pending: number;
6
9
  }, {
7
10
  model: string;
8
11
  devServicePort: number;
9
12
  phVersion: string;
10
- projectDocumentId?: string | undefined;
11
13
  apiKey?: string | undefined;
12
- }, import("@powerhousedao/ph-clint").InferRegistry<readonly [import("document-model").DocumentModelModule<import("@powerhousedao/ph-clint-app").PhClintProjectPHState>]>>;
14
+ }, import("@powerhousedao/ph-clint").InferRegistry<readonly [import("document-model").DocumentModelModule<import("@powerhousedao/ph-clint-app/document-models/ph-clint-project").PhClintProjectPHState>]>>;
@@ -1,28 +1,30 @@
1
1
  /**
2
- * `spec-change` trigger — watches the `powerhouse/ph-clint-project` document
3
- * on the Reactor drive and, when its state changes, enqueues a work item
4
- * that re-runs the code generator in update mode.
2
+ * `spec-change` trigger — watches ALL `powerhouse/ph-clint-project` documents
3
+ * and, when any change, re-runs the code generator for each affected project.
5
4
  *
6
5
  * Flow:
7
6
  * 1. `createDocumentChangeTrigger` (from ph-clint) owns the subscription,
8
- * event coalescing, doc loading, and type narrowing.
9
- * 2. `documentId` resolves the target doc lazily: it prefers
10
- * `config.projectDocumentId`, falling back to a drive scan for the first
11
- * `powerhouse/ph-clint-project` document (and pinning the id into local
12
- * config on success).
13
- * 3. `onChange` converts the doc state to a `ClintProjectSpec`, hashes it,
7
+ * event coalescing, doc loading, and type narrowing. No `documentId` is
8
+ * set, so it loads ALL matching documents on each poll.
9
+ * 2. `getProjectMapping()` merges an on-disk scan (via the clint-project
10
+ * service's projectScanner) with personal-drive folder entries to
11
+ * correlate each document to an on-disk project directory.
12
+ * 3. For each document, `specFromDocumentState()` builds a spec, hashes it,
14
13
  * and compares against `.ph/ph-clint-cli/.last-spec-hash`. If unchanged,
15
- * emits nothing. If different, returns a `function` work item that calls
16
- * `generateProject(spec, workdir, 'update')` and writes the new hash.
14
+ * skips. Otherwise regenerates at the mapped target directory.
17
15
  */
18
16
  import crypto from 'node:crypto';
19
17
  import fs from 'node:fs/promises';
20
18
  import path from 'node:path';
19
+ import { scanProjects, getProjectMapping } from '@powerhousedao/ph-clint';
20
+ import { importSpec } from '@powerhousedao/ph-clint-app/document-models/ph-clint-project';
21
21
  import { createDocumentChangeTrigger } from '../framework.js';
22
22
  import { generateProject } from '../codegen/index.js';
23
23
  import { runPhInit } from '../codegen/scaffold.js';
24
24
  import { specFromDocumentState } from '../spec/from-document.js';
25
+ import { readProjectSpec, writeProjectSpec } from '../spec/file.js';
25
26
  import { syncExternalSkills } from '../skills/sync.js';
27
+ import { clintProject } from '../services/clint-project.js';
26
28
  const DOCUMENT_TYPE = 'powerhouse/ph-clint-project';
27
29
  const HASH_DIR = path.join('.ph', 'ph-clint-cli');
28
30
  const HASH_FILE = '.last-spec-hash';
@@ -51,11 +53,13 @@ async function writeLastSpecHash(targetDir, hash) {
51
53
  await fs.mkdir(path.dirname(file), { recursive: true });
52
54
  await fs.writeFile(file, hash + '\n', 'utf8');
53
55
  }
54
- /** Stable hash of a ClintProjectSpec (sha256 of canonical-key JSON). */
56
+ /** Stable hash of a ClintProjectSpec (sha256 of canonical-key JSON).
57
+ * Excludes documentId/documentType — they don't affect codegen output. */
55
58
  export function hashSpec(spec) {
59
+ const { documentId, documentType, ...codegenRelevant } = spec;
56
60
  return crypto
57
61
  .createHash('sha256')
58
- .update(canonicalJson(spec), 'utf8')
62
+ .update(canonicalJson(codegenRelevant), 'utf8')
59
63
  .digest('hex');
60
64
  }
61
65
  function canonicalJson(value) {
@@ -72,159 +76,161 @@ function canonicalJson(value) {
72
76
  .join(',') +
73
77
  '}');
74
78
  }
75
- /** Find the first ph-clint-project document on the default drive. */
76
- async function findProjectDocumentId(client, driveId) {
77
- try {
78
- const children = await client.getChildren(driveId);
79
- const results = (children?.results ?? []);
80
- for (const node of results) {
81
- if (node.header?.documentType === DOCUMENT_TYPE && node.header.id) {
82
- return node.header.id;
83
- }
84
- }
85
- }
86
- catch {
87
- // fall through
88
- }
89
- return undefined;
79
+ export function specToImportInput(spec) {
80
+ return {
81
+ name: spec.name,
82
+ scope: spec.scope ?? null,
83
+ version: spec.version,
84
+ description: spec.description,
85
+ bin: spec.bin ?? null,
86
+ powerhouse: spec.features.powerhouse,
87
+ mastraEnabled: spec.features.mastra.enabled,
88
+ routineEnabled: spec.features.routine.enabled,
89
+ packages: spec.packages.map(p => ({
90
+ id: p.id,
91
+ packageName: p.packageName,
92
+ documentTypes: p.documentTypes,
93
+ })),
94
+ externalSkills: spec.externalSkills.map(s => ({
95
+ id: s.id,
96
+ name: s.name,
97
+ githubUrl: s.githubUrl,
98
+ })),
99
+ };
90
100
  }
91
101
  export const specChangeTrigger = createDocumentChangeTrigger({
92
102
  id: 'spec-change',
93
103
  documentType: DOCUMENT_TYPE,
94
- // Resolve the target id lazily so we can also do first-time discovery and
95
- // persist the discovered id into local config.
96
- documentId: async (ctx) => {
97
- const configured = ctx.context.config.projectDocumentId &&
98
- ctx.context.config.projectDocumentId.length > 0
99
- ? ctx.context.config.projectDocumentId
100
- : undefined;
101
- if (configured)
102
- return configured;
104
+ // No documentId → watches ALL ph-clint-project docs
105
+ async onChange(docs, ctx) {
106
+ const log = ctx.context.log;
107
+ const folders = ctx.context.folders;
108
+ // Build the mapping: on-disk projects + personal drive documents
109
+ const scanner = clintProject.projectScanner;
110
+ const scanResults = scanner
111
+ ? scanProjects(ctx.context.workdir, scanner)
112
+ : [];
113
+ const mapping = await getProjectMapping(scanResults, folders);
114
+ // Auto-create spec documents for unlinked projects
103
115
  const reactor = await ctx.reactor();
104
- if (!reactor)
105
- return undefined;
106
- const discovered = await findProjectDocumentId(reactor.client, reactor.driveId);
107
- if (!discovered) {
108
- ctx.context.log?.debug(`${TAG} no ${DOCUMENT_TYPE} document found on drive ${reactor.driveId}`);
109
- return undefined;
116
+ if (reactor && folders) {
117
+ for (const entry of mapping) {
118
+ if (entry.path && !entry.documentId) {
119
+ const existingSpec = await readProjectSpec(entry.path);
120
+ if (!existingSpec)
121
+ continue;
122
+ log?.info(`${TAG} creating spec document for unlinked project ${entry.name}`);
123
+ const newDoc = await reactor.client.createEmpty('powerhouse/ph-clint-project');
124
+ const docId = newDoc.header.id;
125
+ await folders.addDocument(docId, `specs/${entry.name}`, entry.name);
126
+ const importInput = specToImportInput(existingSpec);
127
+ await reactor.client.execute(docId, 'main', [importSpec(importInput)]);
128
+ existingSpec.documentId = docId;
129
+ existingSpec.documentType = 'powerhouse/ph-clint-project';
130
+ await writeProjectSpec(entry.path, existingSpec);
131
+ log?.info(`${TAG} linked project ${entry.name} to document ${docId}`);
132
+ }
133
+ }
110
134
  }
111
- // Persist into local config + mutate in-memory so future ticks skip discovery.
112
- try {
113
- const current = await ctx.context.workspace.loadLocalConfig({});
114
- await ctx.context.workspace.storeLocalConfig({
115
- ...current,
116
- projectDocumentId: discovered,
135
+ for (const doc of docs) {
136
+ const spec = specFromDocumentState(doc.state.global, {
137
+ documentId: doc.header.id,
138
+ documentType: doc.header.documentType,
117
139
  });
118
- ctx.context.config.projectDocumentId = discovered;
119
- ctx.context.log?.info(`${TAG} pinned projectDocumentId=${discovered} in ${ctx.context.workspace.getLocalConfigPath()}`);
120
- }
121
- catch (err) {
122
- ctx.context.log?.warn(`${TAG} failed to persist projectDocumentId: ${err instanceof Error ? err.message : String(err)}`);
123
- }
124
- return discovered;
125
- },
126
- async onChange(doc, ctx) {
127
- const log = ctx.context.log;
128
- const spec = specFromDocumentState(doc.state.global);
129
- if (!spec) {
130
- log?.debug(`${TAG} document ${doc.header.id} state did not validate against ClintProjectSpec, skipping`);
131
- return null;
132
- }
133
- const targetDir = ctx.context.workdir;
134
- const nextHash = hashSpec(spec);
135
- const prevHash = await readLastSpecHash(targetDir);
136
- if (nextHash === prevHash) {
137
- log?.debug(`${TAG} spec unchanged (hash=${nextHash.slice(0, 12)}…), nothing to do`);
138
- return null;
139
- }
140
- log?.debug(`${TAG} spec changed: ${prevHash ? prevHash.slice(0, 12) + '…' : '<none>'} → ${nextHash.slice(0, 12)}…`);
141
- return {
142
- type: 'function',
143
- params: {
144
- fn: async () => {
145
- log?.info(`${TAG} regenerating project at ${targetDir} (doc=${doc.header.id})`);
146
- const t0 = Date.now();
147
- const warnings = [];
148
- const result = await generateProject({
140
+ if (!spec) {
141
+ log?.debug(`${TAG} document ${doc.header.id} state did not validate against ClintProjectSpec, skipping`);
142
+ continue;
143
+ }
144
+ // Find target directory from mapping
145
+ const entry = mapping.find((m) => m.documentId === doc.header.id);
146
+ const targetDir = entry?.path ?? path.join(ctx.context.workdir, spec.name);
147
+ // Per-project hash comparison
148
+ const nextHash = hashSpec(spec);
149
+ const prevHash = await readLastSpecHash(targetDir);
150
+ if (nextHash === prevHash) {
151
+ log?.debug(`${TAG} spec for ${spec.name} unchanged (hash=${nextHash.slice(0, 12)}…), skipping`);
152
+ continue;
153
+ }
154
+ log?.debug(`${TAG} spec for ${spec.name} changed: ${prevHash ? prevHash.slice(0, 12) + '…' : '<none>'} → ${nextHash.slice(0, 12)}…`);
155
+ log?.info(`${TAG} regenerating ${spec.name} at ${targetDir} (doc=${doc.header.id})`);
156
+ const t0 = Date.now();
157
+ const warnings = [];
158
+ const result = await generateProject({
159
+ targetDir,
160
+ spec,
161
+ mode: 'update',
162
+ force: true,
163
+ onWarn: (m) => warnings.push(m),
164
+ });
165
+ // Write spec JSON with documentId
166
+ await writeProjectSpec(targetDir, spec);
167
+ await writeLastSpecHash(targetDir, nextHash);
168
+ const elapsed = Date.now() - t0;
169
+ if (log) {
170
+ if (result.migrated)
171
+ log.info(`${TAG} migrated flat → split layout`);
172
+ log.info(`${TAG} regenerated ${spec.name} in ${elapsed}ms: ${result.files.length} written, ${result.skipped.length} skipped, ${result.deleted.length} deleted`);
173
+ for (const w of warnings)
174
+ log.warn(`${TAG} ${w}`);
175
+ }
176
+ // Scaffold the reactor package (app dir) if it hasn't been initialized yet.
177
+ if (result.appDir) {
178
+ const appPkgJson = path.join(result.appDir, 'package.json');
179
+ let appExists = false;
180
+ try {
181
+ await fs.access(appPkgJson);
182
+ appExists = true;
183
+ }
184
+ catch { /* noop */ }
185
+ if (!appExists) {
186
+ log?.info(`${TAG} initializing reactor package at ${result.appDir}`);
187
+ const phResult = await runPhInit({
149
188
  targetDir,
189
+ appDir: result.appDir,
150
190
  spec,
151
- mode: 'update',
152
- force: true,
153
- onWarn: (m) => warnings.push(m),
191
+ log: (msg) => log?.info(`${TAG} ${msg}`),
192
+ stdio: 'ignore',
154
193
  });
155
- await writeLastSpecHash(targetDir, nextHash);
156
- const elapsed = Date.now() - t0;
157
- if (log) {
158
- if (result.migrated)
159
- log.info(`${TAG} migrated flat → split layout`);
160
- log.info(`${TAG} regenerated in ${elapsed}ms: ${result.files.length} written, ${result.skipped.length} skipped, ${result.deleted.length} deleted`);
161
- for (const w of warnings)
162
- log.warn(`${TAG} ${w}`);
194
+ if (!phResult.ran) {
195
+ log?.warn(`${TAG} ph init skipped: ${phResult.reason ?? 'unknown'}`);
163
196
  }
164
- // Scaffold the reactor package (app dir) if it hasn't been initialized yet.
165
- if (result.appDir) {
166
- const appPkgJson = path.join(result.appDir, 'package.json');
167
- let appExists = false;
168
- try {
169
- await fs.access(appPkgJson);
170
- appExists = true;
171
- }
172
- catch { /* noop */ }
173
- if (!appExists) {
174
- log?.info(`${TAG} initializing reactor package at ${result.appDir}`);
175
- const phResult = await runPhInit({
176
- targetDir,
177
- appDir: result.appDir,
178
- spec,
179
- log: (msg) => log?.info(`${TAG} ${msg}`),
180
- stdio: 'ignore',
181
- });
182
- if (!phResult.ran) {
183
- log?.warn(`${TAG} ph init skipped: ${phResult.reason ?? 'unknown'}`);
184
- }
185
- else if (phResult.exitCode !== 0) {
186
- log?.warn(`${TAG} ph init exited with code ${phResult.exitCode}`);
187
- }
188
- }
189
- // Always ensure the app package name matches the spec's scope,
190
- // even if ph init ran in a previous trigger cycle without scope.
191
- if (spec.scope) {
192
- try {
193
- const raw = await fs.readFile(appPkgJson, 'utf8');
194
- const pkg = JSON.parse(raw);
195
- const appFolder = path.basename(result.appDir);
196
- const scopedName = `@${spec.scope}/${appFolder}`;
197
- if (pkg.name !== scopedName) {
198
- pkg.name = scopedName;
199
- pkg.publishConfig = { access: 'public' };
200
- await fs.writeFile(appPkgJson, JSON.stringify(pkg, null, 2) + '\n');
201
- log?.info(`${TAG} patched app package name to ${scopedName}`);
202
- }
203
- }
204
- catch (err) {
205
- log?.warn(`${TAG} could not patch app package name: ${err instanceof Error ? err.message : String(err)}`);
206
- }
207
- }
197
+ else if (phResult.exitCode !== 0) {
198
+ log?.warn(`${TAG} ph init exited with code ${phResult.exitCode}`);
208
199
  }
209
- // Sync external skills after codegen.
210
- if (spec.externalSkills.length > 0) {
211
- const skillsResult = await syncExternalSkills({
212
- targetDir,
213
- desired: spec.externalSkills,
214
- log: (msg) => log?.info(`${TAG} ${msg}`),
215
- });
216
- if (log && (skillsResult.added.length > 0 || skillsResult.removed.length > 0)) {
217
- log.info(`${TAG} skills sync: +${skillsResult.added.length} -${skillsResult.removed.length}`);
200
+ }
201
+ // Always ensure the app package name matches the spec's scope,
202
+ // even if ph init ran in a previous trigger cycle without scope.
203
+ if (spec.scope) {
204
+ try {
205
+ const raw = await fs.readFile(appPkgJson, 'utf8');
206
+ const pkg = JSON.parse(raw);
207
+ const appFolder = path.basename(result.appDir);
208
+ const scopedName = `@${spec.scope}/${appFolder}`;
209
+ if (pkg.name !== scopedName) {
210
+ pkg.name = scopedName;
211
+ pkg.publishConfig = { access: 'public' };
212
+ await fs.writeFile(appPkgJson, JSON.stringify(pkg, null, 2) + '\n');
213
+ log?.info(`${TAG} patched app package name to ${scopedName}`);
218
214
  }
219
215
  }
220
- },
221
- },
222
- callbacks: {
223
- onFailure: (err) => {
224
- log?.error(`${TAG} regen failed: ${err.message}`);
225
- log?.debug(`${TAG} stack: ${err.stack ?? '<no stack>'}`);
226
- },
227
- },
228
- };
216
+ catch (err) {
217
+ log?.warn(`${TAG} could not patch app package name: ${err instanceof Error ? err.message : String(err)}`);
218
+ }
219
+ }
220
+ }
221
+ // Sync external skills after codegen.
222
+ if (spec.externalSkills.length > 0) {
223
+ const skillsResult = await syncExternalSkills({
224
+ targetDir,
225
+ desired: spec.externalSkills,
226
+ log: (msg) => log?.info(`${TAG} ${msg}`),
227
+ });
228
+ if (log && (skillsResult.added.length > 0 || skillsResult.removed.length > 0)) {
229
+ log.info(`${TAG} skills sync: +${skillsResult.added.length} -${skillsResult.removed.length}`);
230
+ }
231
+ }
232
+ }
233
+ // Work done inline, no additional work item needed
234
+ return null;
229
235
  },
230
236
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@powerhousedao/ph-clint-cli",
3
- "version": "0.1.0-dev.20",
3
+ "version": "0.1.0-dev.22",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -10,8 +10,7 @@
10
10
  "ph-clint": "./dist/main.js"
11
11
  },
12
12
  "files": [
13
- "dist",
14
- "prompts"
13
+ "dist"
15
14
  ],
16
15
  "scripts": {
17
16
  "build:skills": "tsx scripts/build-skills.ts",
@@ -29,9 +28,9 @@
29
28
  "@mastra/core": "^1.22.0",
30
29
  "@mastra/libsql": "^1.7.4",
31
30
  "@mastra/memory": "^1.13.1",
32
- "@powerhousedao/ph-clint": "0.1.0-dev.18",
33
- "@powerhousedao/ph-clint-app": "^0.1.0-dev.20",
34
- "@powerhousedao/ph-clint-dev": "0.1.0-dev.18",
31
+ "@powerhousedao/ph-clint": "^0.1.0-dev.22",
32
+ "@powerhousedao/ph-clint-app": "^0.1.0-dev.22",
33
+ "@powerhousedao/ph-clint-dev": "^0.1.0-dev.22",
35
34
  "@powerhousedao/reactor": "6.0.0-dev.170",
36
35
  "@powerhousedao/reactor-api": "6.0.0-dev.170",
37
36
  "@powerhousedao/shared": "6.0.0-dev.170",
@@ -1,13 +0,0 @@
1
- # Agent Base System Prompt
2
-
3
- You are {{agentName}}, an agent available through the {{cliName}} CLI.
4
-
5
- ## Capabilities
6
-
7
- This base profile is a minimal placeholder. Extend it with project-specific
8
- context, workflows, and domain knowledge. Additional sections listed in the
9
- agent's `sections` array are concatenated after this file at build time.
10
-
11
- See `examples/05-ph-rupert/prompts/agent-profiles/AgentBase.md` for a more
12
- complete example covering Powerhouse document system fundamentals,
13
- collaboration documents (inbox, WBS), and task management guidelines.
File without changes
File without changes