@ghl-ai/aw 0.1.67 → 0.1.69

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.
@@ -0,0 +1,219 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { AW_DOCS_DIR } from './constants.mjs';
4
+
5
+ export const PLAN_ARTIFACTS = [
6
+ { kind: 'prd', source: 'prd.md', html: 'prd.html' },
7
+ { kind: 'design', source: 'design.md', html: 'design.html' },
8
+ { kind: 'spec', source: 'spec.md', html: 'spec.html' },
9
+ { kind: 'tasks', source: 'tasks.md', html: 'tasks.html' },
10
+ ];
11
+
12
+ const GENERATED_STATUSES = new Set(['generated', 'published', 'html_generated_and_published']);
13
+ const SKIPPED_STATUSES = new Set(['skipped']);
14
+
15
+ export function findAwDocsProjectRoot(cwd = process.cwd()) {
16
+ let current = resolve(cwd);
17
+ while (true) {
18
+ if (existsSync(join(current, AW_DOCS_DIR))) return current;
19
+ const next = dirname(current);
20
+ if (next === current) return resolve(cwd);
21
+ current = next;
22
+ }
23
+ }
24
+
25
+ export function normalizeFeatureSlug(value) {
26
+ const raw = String(value || '').trim().replace(/\\/g, '/').replace(/\/+$/, '');
27
+ if (!raw || raw === 'true') return '';
28
+ const match = raw.match(/^(?:\.aw_docs\/)?features\/([^/]+)$/);
29
+ const slug = match ? match[1] : raw;
30
+ if (!/^[A-Za-z0-9._-]+$/.test(slug)) {
31
+ throw new Error(`Invalid feature slug "${slug}". Feature slugs may contain letters, numbers, dot, underscore, and dash only.`);
32
+ }
33
+ return slug;
34
+ }
35
+
36
+ export function resolveDocsOutputMode(projectRoot, env = process.env) {
37
+ const configPath = join(projectRoot, AW_DOCS_DIR, 'config.json');
38
+ const envMode = env.AW_DOCS_OUTPUT_MODE;
39
+ let configMode = '';
40
+
41
+ if (existsSync(configPath)) {
42
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
43
+ configMode = config.docs?.outputMode || '';
44
+ }
45
+
46
+ return normalizeOutputMode(envMode || configMode || 'dual');
47
+ }
48
+
49
+ function normalizeOutputMode(value) {
50
+ const mode = String(value || 'dual').trim().toLowerCase();
51
+ if (mode === 'md' || mode === 'markdown-only') return 'markdown';
52
+ if (mode === 'html-only') return 'html';
53
+ if (mode === 'markdown' || mode === 'html' || mode === 'dual') return mode;
54
+ return 'dual';
55
+ }
56
+
57
+ export function readFeatureState(featureDir) {
58
+ const statePath = join(featureDir, 'state.json');
59
+ if (!existsSync(statePath)) return { statePath, state: null };
60
+ return { statePath, state: JSON.parse(readFileSync(statePath, 'utf8')) };
61
+ }
62
+
63
+ export function listExpectedPlanArtifacts(featureDir, { full = false } = {}) {
64
+ return PLAN_ARTIFACTS.filter(artifact => full || existsSync(join(featureDir, artifact.source)));
65
+ }
66
+
67
+ export function validateFeatureDocs(options) {
68
+ const projectRoot = resolve(options.projectRoot || findAwDocsProjectRoot(options.cwd));
69
+ const featureSlug = normalizeFeatureSlug(options.featureSlug);
70
+ if (!featureSlug) throw new Error('Missing feature slug. Use: aw docs validate --feature <feature-slug>');
71
+
72
+ const featureDir = join(projectRoot, AW_DOCS_DIR, 'features', featureSlug);
73
+ const result = {
74
+ ok: true,
75
+ projectRoot,
76
+ featureSlug,
77
+ featureDir,
78
+ outputMode: normalizeOutputMode(options.outputMode || resolveDocsOutputMode(projectRoot, options.env)),
79
+ checked: [],
80
+ errors: [],
81
+ warnings: [],
82
+ };
83
+
84
+ if (!existsSync(featureDir) || !statSync(featureDir).isDirectory()) {
85
+ addError(result, `Missing feature folder: ${AW_DOCS_DIR}/features/${featureSlug}`);
86
+ return result;
87
+ }
88
+
89
+ const { statePath, state } = readFeatureState(featureDir);
90
+ if (!state) {
91
+ addError(result, `Missing state file: ${relativeFeaturePath(projectRoot, statePath)}`);
92
+ }
93
+
94
+ const expected = listExpectedPlanArtifacts(featureDir, { full: options.full });
95
+ if (expected.length === 0) {
96
+ addError(result, options.full
97
+ ? 'Full plan validation requires prd.md, design.md, spec.md, and tasks.md.'
98
+ : 'No canonical planning Markdown found to validate.');
99
+ }
100
+
101
+ const markdownOnly = result.outputMode === 'markdown';
102
+ for (const artifact of expected) {
103
+ validateArtifact(result, artifact, { projectRoot, featureSlug, featureDir, state, markdownOnly, publishRequired: options.publishRequired });
104
+ }
105
+
106
+ if (state && expected.length > 0 && marksReadyForBuild(state) && result.errors.length > 0) {
107
+ result.warnings.push('state.json marks this feature ready for build while the HTML companion gate is failing.');
108
+ }
109
+
110
+ return result;
111
+ }
112
+
113
+ function validateArtifact(result, artifact, context) {
114
+ const sourcePath = join(context.featureDir, artifact.source);
115
+ const htmlPath = join(context.featureDir, artifact.html);
116
+ const sourceRel = featureRel(context.featureSlug, artifact.source);
117
+ const htmlRel = featureRel(context.featureSlug, artifact.html);
118
+ const entry = findCompanionEntry(context.state?.html_companion_artifacts, { sourceRel, htmlRel, kind: artifact.kind });
119
+
120
+ const record = {
121
+ kind: artifact.kind,
122
+ source: sourceRel,
123
+ html: htmlRel,
124
+ state: entry || null,
125
+ };
126
+ result.checked.push(record);
127
+
128
+ if (!existsSync(sourcePath)) {
129
+ addError(result, `Missing planning Markdown: ${sourceRel}`);
130
+ return;
131
+ }
132
+
133
+ if (context.markdownOnly || isExplicitMarkdownSkip(entry)) {
134
+ if (!context.markdownOnly && !isExplicitMarkdownSkip(entry)) {
135
+ addError(result, `Invalid Markdown-only skip for ${artifact.html}: missing skip_reason explicit_markdown_only.`);
136
+ }
137
+ return;
138
+ }
139
+
140
+ if (!existsSync(htmlPath)) {
141
+ addError(result, `Missing HTML companion for ${artifact.source}: ${htmlRel}`);
142
+ }
143
+
144
+ if (!entry) {
145
+ addError(result, `Missing state.json html_companion_artifacts entry for ${artifact.html}`);
146
+ return;
147
+ }
148
+
149
+ if (!GENERATED_STATUSES.has(String(entry.status || '').toLowerCase())) {
150
+ addError(result, `Invalid companion status for ${artifact.html}: expected generated, got ${entry.status || 'missing'}`);
151
+ }
152
+
153
+ if (!entry.owner || !entry.execution_mode || !entry.runner) {
154
+ addError(result, `Incomplete companion provenance for ${artifact.html}: owner, execution_mode, and runner are required`);
155
+ }
156
+
157
+ if (context.publishRequired && !hasPublishedOrBlockedLink(entry)) {
158
+ addError(result, `Missing publish result for ${artifact.html}: expected published links or a concrete publish blocker`);
159
+ }
160
+ }
161
+
162
+ function findCompanionEntry(companions, expected) {
163
+ if (!companions) return null;
164
+ const values = Array.isArray(companions)
165
+ ? companions
166
+ : Object.entries(companions).map(([key, value]) => ({ key, value }));
167
+
168
+ for (const item of values) {
169
+ const entry = item?.value && typeof item.value === 'object' ? item.value : item;
170
+ const key = item?.key || '';
171
+ if (!entry || typeof entry !== 'object') continue;
172
+ if (key === expected.htmlRel || key === basename(expected.htmlRel)) return entry;
173
+ if (pathMatches(entry.html_path || entry.primary || entry.publish_copy, expected.htmlRel)) return entry;
174
+ if (pathMatches(entry.source_path, expected.sourceRel)) return entry;
175
+ if (entry.artifact_kind === expected.kind || entry.kind === expected.kind) return entry;
176
+ }
177
+
178
+ return null;
179
+ }
180
+
181
+ function isExplicitMarkdownSkip(entry) {
182
+ if (!entry || typeof entry !== 'object') return false;
183
+ const status = String(entry.status || '').toLowerCase();
184
+ return SKIPPED_STATUSES.has(status) && entry.skip_reason === 'explicit_markdown_only';
185
+ }
186
+
187
+ function hasPublishedOrBlockedLink(entry) {
188
+ const publishStatus = String(entry.publish_status || '').toLowerCase();
189
+ const links = entry.remote_links || {};
190
+ return publishStatus === 'published'
191
+ || Boolean(entry.remote_url || entry.teamofone_url || entry.devtools_url || links.devtools || links.teamofone)
192
+ || (publishStatus === 'blocked' && Boolean(entry.publish_blocker || entry.blocked_reason));
193
+ }
194
+
195
+ function marksReadyForBuild(state) {
196
+ const values = [state.status, state.stage_status, state.planning_status, state.next_stage].map(value => String(value || '').toLowerCase());
197
+ return values.some(value => value.includes('ready_for_build') || value === 'build');
198
+ }
199
+
200
+ function pathMatches(value, expectedRel) {
201
+ if (!value) return false;
202
+ const normalized = String(value).replace(/\\/g, '/').replace(/^\.\//, '');
203
+ const expected = expectedRel.replace(/\\/g, '/').replace(/^\.\//, '');
204
+ return normalized === expected || normalized.endsWith(`/${expected}`);
205
+ }
206
+
207
+ function featureRel(featureSlug, filename) {
208
+ return `${AW_DOCS_DIR}/features/${featureSlug}/${filename}`;
209
+ }
210
+
211
+ function relativeFeaturePath(projectRoot, absPath) {
212
+ if (!isAbsolute(absPath)) return absPath;
213
+ return absPath.startsWith(projectRoot) ? absPath.slice(projectRoot.length + 1) : absPath;
214
+ }
215
+
216
+ function addError(result, message) {
217
+ result.ok = false;
218
+ result.errors.push(message);
219
+ }
package/cli.mjs CHANGED
@@ -19,6 +19,7 @@ const COMMANDS = {
19
19
  drop: () => import('./commands/drop.mjs').then(m => m.dropCommand),
20
20
  status: () => import('./commands/status.mjs').then(m => m.statusCommand),
21
21
  doctor: () => import('./commands/doctor.mjs').then(m => m.doctorCommand),
22
+ docs: () => import('./commands/docs.mjs').then(m => m.docsCommand),
22
23
  routing: () => import('./commands/startup.mjs').then(m => m.routingCommand),
23
24
  startup: () => import('./commands/startup.mjs').then(m => m.startupCommand),
24
25
  mcp: () => import('./commands/mcp.mjs').then(m => m.mcpCommand),
@@ -159,6 +160,7 @@ function printHelp() {
159
160
  cmd('aw search <query>', 'Find agents & skills (local + registry)'),
160
161
 
161
162
  sec('Manage'),
163
+ cmd('aw docs validate --feature <slug>', 'Validate .aw_docs planning HTML companions before build handoff'),
162
164
  cmd('aw status', 'Show synced paths, modified files & conflicts'),
163
165
  cmd('aw doctor', 'Run a health check for routing, MCP, plugin, and AW ECC surfaces'),
164
166
  cmd('aw link', 'Link current project as a git worktree (wires IDE symlinks)'),
@@ -0,0 +1,78 @@
1
+ import * as fmt from '../fmt.mjs';
2
+ import { chalk } from '../fmt.mjs';
3
+ import { validateFeatureDocs, normalizeFeatureSlug } from '../aw-docs-validate.mjs';
4
+
5
+ export async function docsCommand(args) {
6
+ const subcommand = args._positional?.[0] || 'validate';
7
+ if (args['--help'] || subcommand === 'help') {
8
+ printDocsHelp();
9
+ return;
10
+ }
11
+
12
+ if (!['validate', 'check', 'verify'].includes(subcommand)) {
13
+ fmt.cancel(`Unknown aw docs command: ${subcommand}\nRun aw docs --help for usage.`);
14
+ }
15
+
16
+ const featureInput = args['--feature'] || args._positional?.[1];
17
+ let featureSlug = '';
18
+ try {
19
+ featureSlug = normalizeFeatureSlug(featureInput);
20
+ } catch (error) {
21
+ fmt.cancel(error.message);
22
+ }
23
+ if (!featureSlug) {
24
+ fmt.cancel('Missing feature slug. Use: aw docs validate --feature <feature-slug>');
25
+ }
26
+ const result = validateFeatureDocs({
27
+ cwd: process.cwd(),
28
+ featureSlug,
29
+ full: args['--full'] === true,
30
+ outputMode: args['--output-mode'],
31
+ publishRequired: args['--publish-required'] === true,
32
+ });
33
+
34
+ if (args['--json'] === true) {
35
+ console.log(JSON.stringify(result, null, 2));
36
+ if (!result.ok) process.exitCode = 1;
37
+ return;
38
+ }
39
+
40
+ fmt.intro('aw docs validate');
41
+
42
+ const checked = result.checked.map(item => ` ${item.kind.padEnd(6)} ${chalk.dim(item.source)} -> ${chalk.dim(item.html)}`).join('\n');
43
+ fmt.note([
44
+ `${chalk.dim('feature:')} ${result.featureSlug}`,
45
+ `${chalk.dim('outputMode:')} ${result.outputMode}`,
46
+ `${chalk.dim('checked:')} ${result.checked.length}`,
47
+ ].join('\n'), 'Scope');
48
+
49
+ if (checked) fmt.note(checked, 'Planning Companions');
50
+
51
+ for (const warning of result.warnings) {
52
+ fmt.logWarn(warning);
53
+ }
54
+
55
+ if (!result.ok) {
56
+ fmt.note(result.errors.map(error => ` - ${error}`).join('\n'), chalk.red('Blocking Issues'));
57
+ fmt.cancel('AW docs validation failed. Run platform-core:echo-direct or record an explicit blocker before marking this feature ready.');
58
+ }
59
+
60
+ fmt.outro('⟁ AW docs validation passed');
61
+ }
62
+
63
+ function printDocsHelp() {
64
+ console.log([
65
+ 'Usage:',
66
+ ' aw docs validate --feature <slug> [--full] [--publish-required] [--json]',
67
+ ' aw docs validate .aw_docs/features/<slug> [--full]',
68
+ '',
69
+ 'Validates AW planning HTML companions before ready_for_build handoff.',
70
+ '',
71
+ 'Options:',
72
+ ' --feature <slug> Feature slug under .aw_docs/features/',
73
+ ' --full Require prd/design/spec/tasks Markdown and HTML companions',
74
+ ' --publish-required Require Devtools/GitHub publish links or a concrete publish blocker',
75
+ ' --output-mode <mode> Override docs output mode: dual, html, or markdown',
76
+ ' --json Print machine-readable result',
77
+ ].join('\n'));
78
+ }
package/commands/push.mjs CHANGED
@@ -37,9 +37,9 @@ import {
37
37
  AW_DOCS_SEED_BRANCH,
38
38
  AW_DOCS_PUBLISH_DIR,
39
39
  AW_DOCS_PUBLIC_BASE_URL,
40
- AW_DOCS_TEAMOFONE_ORIGIN,
41
40
  AW_DOCS_TEAMOFONE_BASE_URL,
42
41
  AW_CO_AUTHOR,
42
+ canonicalAwDocsDevtoolsBaseUrl,
43
43
  defaultAwDocsConfig,
44
44
  } from '../constants.mjs';
45
45
  import { resolveInput } from '../paths.mjs';
@@ -252,7 +252,7 @@ function ensureAwDocsPublishConfig(projectRoot) {
252
252
  ...defaultConfig.sync.github_docs,
253
253
  ...(config.sync?.github_docs || {}),
254
254
  };
255
- githubDocs.teamofone_base_url = canonicalDevtoolsBaseUrl(githubDocs.teamofone_base_url);
255
+ githubDocs.teamofone_base_url = canonicalAwDocsDevtoolsBaseUrl(githubDocs.teamofone_base_url);
256
256
 
257
257
  const next = {
258
258
  ...config,
@@ -306,7 +306,7 @@ function resolveAwDocsPublishConfig(projectRoot) {
306
306
  branch,
307
307
  seedBranch: process.env.AW_DOCS_SEED_BRANCH || githubDocs.seed_branch || AW_DOCS_SEED_BRANCH,
308
308
  dest,
309
- teamofoneBaseUrl: canonicalDevtoolsBaseUrl(teamofoneBaseUrl),
309
+ teamofoneBaseUrl: canonicalAwDocsDevtoolsBaseUrl(teamofoneBaseUrl),
310
310
  publicBaseUrl,
311
311
  };
312
312
  }
@@ -554,34 +554,6 @@ function appendPathToUrl(baseUrl, path) {
554
554
  return `${basePath.replace(/\/$/, '')}/${encodedPath}${query}${hash}`;
555
555
  }
556
556
 
557
- function normalizeTeamOfOneBaseUrl(baseUrl) {
558
- const value = String(baseUrl || '').trim();
559
- if (!value) return '';
560
- if (/^https?:\/\//i.test(value)) return value;
561
- if (value.startsWith('/')) return `${AW_DOCS_TEAMOFONE_ORIGIN.replace(/\/$/, '')}${value}`;
562
- return `https://${value.replace(/^\/+/, '')}`;
563
- }
564
-
565
- function canonicalDevtoolsBaseUrl(baseUrl) {
566
- const canonical = AW_DOCS_TEAMOFONE_BASE_URL.replace(/\/+$/, '');
567
- const normalized = normalizeTeamOfOneBaseUrl(baseUrl).replace(/\/+$/, '');
568
- if (!normalized) return canonical;
569
-
570
- try {
571
- const expected = new URL(canonical);
572
- const actual = new URL(normalized);
573
- const expectedPath = expected.pathname.replace(/\/+$/, '');
574
- const actualPath = actual.pathname.replace(/\/+$/, '');
575
- if (actual.origin === expected.origin && actualPath === expectedPath) {
576
- return canonical;
577
- }
578
- } catch {
579
- return canonical;
580
- }
581
-
582
- return canonical;
583
- }
584
-
585
557
  function appendQueryParam(url, key, value) {
586
558
  const hashIndex = url.indexOf('#');
587
559
  const withoutHash = hashIndex === -1 ? url : url.slice(0, hashIndex);
package/constants.mjs CHANGED
@@ -40,6 +40,34 @@ export const AW_DOCS_TEAMOFONE_ORIGIN = AW_DOCS_DEVTOOLS_ORIGIN;
40
40
  /** @deprecated Use AW_DOCS_DEVTOOLS_BASE_URL. Kept as an alias for older config keys. */
41
41
  export const AW_DOCS_TEAMOFONE_BASE_URL = AW_DOCS_DEVTOOLS_BASE_URL;
42
42
 
43
+ function normalizeAwDocsShareBaseUrl(baseUrl) {
44
+ const value = String(baseUrl || '').trim();
45
+ if (!value) return '';
46
+ if (/^https?:\/\//i.test(value)) return value;
47
+ if (value.startsWith('/')) return `${AW_DOCS_DEVTOOLS_ORIGIN.replace(/\/$/, '')}${value}`;
48
+ return `https://${value.replace(/^\/+/, '')}`;
49
+ }
50
+
51
+ export function canonicalAwDocsDevtoolsBaseUrl(baseUrl) {
52
+ const canonical = AW_DOCS_DEVTOOLS_BASE_URL.replace(/\/+$/, '');
53
+ const normalized = normalizeAwDocsShareBaseUrl(baseUrl).replace(/\/+$/, '');
54
+ if (!normalized) return canonical;
55
+
56
+ try {
57
+ const expected = new URL(canonical);
58
+ const actual = new URL(normalized);
59
+ const expectedPath = expected.pathname.replace(/\/+$/, '');
60
+ const actualPath = actual.pathname.replace(/\/+$/, '');
61
+ if (actual.origin === expected.origin && actualPath === expectedPath) {
62
+ return canonical;
63
+ }
64
+ } catch {
65
+ return canonical;
66
+ }
67
+
68
+ return canonical;
69
+ }
70
+
43
71
  export function defaultAwDocsGithubDocsConfig() {
44
72
  return {
45
73
  enabled: true,
package/ecc.mjs CHANGED
@@ -12,7 +12,7 @@ import { applyStoredStartupPreferences } from "./startup.mjs";
12
12
 
13
13
  const AW_ECC_REPO_SSH = "git@github.com:shreyansh-ghl/aw-ecc.git";
14
14
  const AW_ECC_REPO_HTTPS = "https://github.com/shreyansh-ghl/aw-ecc.git";
15
- export const AW_ECC_TAG = "v1.4.65";
15
+ export const AW_ECC_TAG = "v1.4.66";
16
16
  const REQUIRED_ECC_FILES = [
17
17
  "package.json",
18
18
  "scripts/install-apply.js",
package/integrate.mjs CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  import { isDefaultRoutingEnabled } from './startup.mjs';
16
16
  import {
17
17
  AW_DOCS_DIR,
18
+ canonicalAwDocsDevtoolsBaseUrl,
18
19
  defaultAwDocsConfig,
19
20
  } from './constants.mjs';
20
21
 
@@ -665,6 +666,7 @@ No active runs. Use \`/aw:<team>-<command>\` to start a workflow.
665
666
  github_docs: {
666
667
  ...defaultConfig.sync.github_docs,
667
668
  ...(existing.sync?.github_docs || {}),
669
+ teamofone_base_url: canonicalAwDocsDevtoolsBaseUrl(existing.sync?.github_docs?.teamofone_base_url),
668
670
  },
669
671
  },
670
672
  paths: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,6 +29,7 @@
29
29
  "slack-sim/",
30
30
  "file-tree.mjs",
31
31
  "apply.mjs",
32
+ "aw-docs-validate.mjs",
32
33
  "hook-cleanup.mjs",
33
34
  "hook-manifest.mjs",
34
35
  "update.mjs",